feat: improve OpenAPI request contracts and examples (#418)

* feat: improve OpenAPI request contracts and examples

* fix: align AJV docs plugin typing

* fix: preserve runtime behavior for OpenAPI schemas

* fix: align medication OpenAPI body schema with app payloads
This commit is contained in:
Daniel Volz
2026-03-11 14:50:42 +01:00
committed by GitHub
parent dd8ddb64e6
commit c13bfad16f
27 changed files with 3511 additions and 2190 deletions
+3
View File
@@ -30,6 +30,7 @@ import { settingsRoutes } from "./routes/settings.js";
import { shareRoutes } from "./routes/share.js";
import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js";
import { startReminderScheduler } from "./services/reminder-scheduler.js";
import { documentationSchemaAjv } from "./utils/documentation-schema-keywords.js";
// Re-export utilities from server-config for external use
export {
@@ -156,6 +157,7 @@ export async function createApp(options?: {
const app = Fastify({
logger: buildLoggerOptions(opts.logLevel),
genReqId: (request) => sanitizeCorrelationId(request.headers) ?? randomUUID(),
ajv: documentationSchemaAjv,
});
app.addHook("onRequest", (request, reply, done) => {
@@ -231,6 +233,7 @@ const imagesDir = ensureImagesDirectory();
const app = Fastify({
logger: buildLoggerOptions(env.LOG_LEVEL),
genReqId: (request) => sanitizeCorrelationId(request.headers) ?? randomUUID(),
ajv: documentationSchemaAjv,
});
app.addHook("onRequest", (request, reply, done) => {
+5
View File
@@ -167,6 +167,11 @@ export async function apiKeyRoutes(app: FastifyInstance) {
scope: { type: "string", enum: ["read", "write"], default: "write" },
expiresInDays: { type: "number", minimum: 1, maximum: 3650 },
},
example: {
name: "Home Assistant integration",
scope: "write",
expiresInDays: 365,
},
},
response: {
201: {
+13
View File
@@ -176,6 +176,10 @@ export async function authRoutes(app: FastifyInstance) {
username: { type: "string", minLength: 3, maxLength: 50 },
password: { type: "string", minLength: 8, maxLength: 128 },
},
example: {
username: "daniel",
password: "correct-horse-battery-staple",
},
},
response: {
201: {
@@ -274,6 +278,11 @@ export async function authRoutes(app: FastifyInstance) {
password: { type: "string" },
rememberMe: { type: "boolean" },
},
example: {
username: "daniel",
password: "correct-horse-battery-staple",
rememberMe: true,
},
},
response: {
200: {
@@ -577,6 +586,10 @@ export async function authRoutes(app: FastifyInstance) {
currentPassword: { type: "string" },
newPassword: { type: "string", minLength: 8, maxLength: 128 },
},
example: {
currentPassword: "current-password",
newPassword: "new-strong-password",
},
},
response: {
200: {
+208 -40
View File
@@ -6,7 +6,12 @@ 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 { applyOpenApiRouteStandards } from "../utils/openapi-route-standards.js";
import {
applyOpenApiRouteStandards,
genericErrorSchema,
tokenParamsSchema,
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
import { parseIntakesJson, parseTakenByJson, personTakesMedication } from "../utils/scheduler-utils.js";
// =============================================================================
@@ -31,6 +36,25 @@ const protectedEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
const doseReadResponseSchema = {
type: "object",
properties: {
doses: {
type: "array",
items: {
type: "object",
properties: {
doseId: { type: "string" },
takenAt: { type: "number" },
markedBy: { type: ["string", "null"] },
takenSource: { type: "string" },
dismissed: { type: "boolean" },
},
},
},
},
} as const;
function maskToken(token: string): string {
if (token.length <= 8) return token;
return `${token.slice(0, 4)}...${token.slice(-4)}`;
@@ -153,7 +177,18 @@ export async function doseRoutes(app: FastifyInstance) {
// ---------------------------------------------------------------------------
app.get(
"/doses/taken",
{ preHandler: requireAuth, logLevel: "warn", schema: { tags: ["doses"], security: protectedEndpointSecurity } },
{
preHandler: requireAuth,
logLevel: "warn",
schema: {
tags: ["doses"],
security: protectedEndpointSecurity,
response: {
200: doseReadResponseSchema,
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
@@ -177,7 +212,33 @@ export async function doseRoutes(app: FastifyInstance) {
// ---------------------------------------------------------------------------
app.post<{ Body: z.infer<typeof markDoseSchema> }>(
"/doses/taken",
{ preHandler: requireAuth, schema: { tags: ["doses"], security: protectedEndpointSecurity } },
{
preHandler: requireAuth,
schema: {
tags: ["doses"],
security: protectedEndpointSecurity,
body: {
type: "object",
properties: {
doseId: { type: "string" },
},
example: {
doseId: "1:2026-03-11T08:00:00.000Z:Daniel",
},
},
response: {
200: {
type: "object",
properties: {
success: { type: "boolean" },
message: { type: "string" },
},
},
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
@@ -217,7 +278,24 @@ export async function doseRoutes(app: FastifyInstance) {
// ---------------------------------------------------------------------------
app.delete<{ Params: { doseId: string } }>(
"/doses/taken/:doseId",
{ preHandler: requireAuth, schema: { tags: ["doses"], security: protectedEndpointSecurity } },
{
preHandler: requireAuth,
schema: {
tags: ["doses"],
security: protectedEndpointSecurity,
params: {
type: "object",
required: ["doseId"],
properties: {
doseId: { type: "string", minLength: 1 },
},
},
response: {
200: { type: "object", properties: { success: { type: "boolean" } } },
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
@@ -246,7 +324,33 @@ export async function doseRoutes(app: FastifyInstance) {
// ---------------------------------------------------------------------------
app.post<{ Body: z.infer<typeof dismissDosesSchema> }>(
"/doses/dismiss",
{ preHandler: requireAuth, schema: { tags: ["doses"], security: protectedEndpointSecurity } },
{
preHandler: requireAuth,
schema: {
tags: ["doses"],
security: protectedEndpointSecurity,
body: {
type: "object",
properties: {
doseIds: { type: "array", items: { type: "string" } },
},
example: {
doseIds: ["1:2026-03-11T08:00:00.000Z:Daniel", "1:2026-03-11T20:00:00.000Z:Daniel"],
},
},
response: {
200: {
type: "object",
properties: {
success: { type: "boolean" },
dismissedCount: { type: "integer" },
},
},
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
@@ -299,7 +403,23 @@ export async function doseRoutes(app: FastifyInstance) {
// ---------------------------------------------------------------------------
app.delete(
"/doses/dismiss",
{ preHandler: requireAuth, schema: { tags: ["doses"], security: protectedEndpointSecurity } },
{
preHandler: requireAuth,
schema: {
tags: ["doses"],
security: protectedEndpointSecurity,
response: {
200: {
type: "object",
properties: {
success: { type: "boolean" },
clearedCount: { type: "integer" },
},
},
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
@@ -333,6 +453,13 @@ export async function doseRoutes(app: FastifyInstance) {
app.get<{ Params: { token: string } }>(
"/share/:token/doses",
{
schema: {
params: tokenParamsSchema,
response: {
200: doseReadResponseSchema,
404: genericErrorSchema,
},
},
logLevel: "warn",
config: {
rateLimit: {
@@ -371,6 +498,25 @@ export async function doseRoutes(app: FastifyInstance) {
// ---------------------------------------------------------------------------
app.post<{ Params: { token: string }; Body: z.infer<typeof shareDoseSchema> }>(
"/share/:token/doses",
{
schema: {
params: tokenParamsSchema,
body: {
type: "object",
properties: {
doseId: { type: "string" },
},
example: {
doseId: "1:2026-03-11T08:00:00.000Z:Daniel",
},
},
response: {
200: { type: "object", properties: { success: { type: "boolean" }, message: { type: "string" } } },
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
404: genericErrorSchema,
},
},
},
async (request, reply) => {
const { token } = request.params;
@@ -427,40 +573,62 @@ export async function doseRoutes(app: FastifyInstance) {
// ---------------------------------------------------------------------------
// 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;
app.delete<{ Params: { token: string; doseId: string } }>(
"/share/:token/doses/:doseId",
{
schema: {
params: {
type: "object",
required: ["token", "doseId"],
properties: {
token: tokenParamsSchema.properties.token,
doseId: { type: "string", minLength: 1 },
},
},
response: {
200: { type: "object", properties: { success: { type: "boolean" } } },
400: genericErrorSchema,
404: genericErrorSchema,
},
},
},
async (request, reply) => {
const { token, doseId } = request.params;
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 { 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()
.from(doseTracking)
.where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
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 };
}
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()
.from(doseTracking)
.where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
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 };
});
);
}
+315 -213
View File
@@ -10,7 +10,11 @@ import { doseTracking, medications, refillHistory, shareTokens, userSettings } f
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js";
import { applyOpenApiRouteStandards } from "../utils/openapi-route-standards.js";
import {
applyOpenApiRouteStandards,
genericErrorSchema,
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
import { normalizePackageType, PACKAGE_TYPES } from "../utils/package-profiles.js";
import { parseIntakesJson, parseTakenByJson } from "../utils/scheduler-utils.js";
@@ -146,6 +150,69 @@ const importDataSchema = z.object({
shareLinks: z.array(shareLinkSchema).default([]),
});
const exportQuerystringSchema = {
type: "object",
properties: {
includeSensitive: { type: "string", enum: ["true", "false"] },
includeImages: { type: "string", enum: ["true", "false"] },
},
} as const;
const exportResponseSchema = {
type: "object",
properties: {
version: { type: "string" },
exportedAt: { type: "string", format: "date-time" },
includeSensitiveData: { type: "boolean" },
medications: { type: "array", items: { type: "object", additionalProperties: true } },
doseHistory: { type: "array", items: { type: "object", additionalProperties: true } },
refillHistory: { type: "array", items: { type: "object", additionalProperties: true } },
settings: { type: "object", additionalProperties: true },
shareLinks: { type: "array", items: { type: "object", additionalProperties: true } },
},
} as const;
const importBodyOpenApiSchema = {
type: "object",
required: ["version", "exportedAt"],
properties: {
version: { type: "string" },
exportedAt: { type: "string", format: "date-time" },
includeSensitiveData: { type: "boolean" },
medications: { type: "array", items: { type: "object", additionalProperties: true } },
doseHistory: { type: "array", items: { type: "object", additionalProperties: true } },
refillHistory: { type: "array", items: { type: "object", additionalProperties: true } },
settings: { type: "object", additionalProperties: true },
shareLinks: { type: "array", items: { type: "object", additionalProperties: true } },
},
example: {
version: "1.8.0",
exportedAt: "2026-03-11T10:15:00.000Z",
includeSensitiveData: true,
medications: [
{
name: "Ibuprofen 400",
packageType: "box",
packCount: 1,
looseTablets: 8,
intakes: [
{
usage: 1,
every: 8,
start: "2026-03-11T08:00:00.000Z",
takenBy: "Daniel",
remind: true,
},
],
},
],
doseHistory: [{ doseId: "1:2026-03-11T08:00:00.000Z:Daniel", takenAt: 1773216000000 }],
refillHistory: [{ packsAdded: 1, loosePillsAdded: 4, refillDate: "2026-03-10T12:00:00.000Z" }],
settings: { language: "en", stockCalculationMode: "automatic" },
shareLinks: [{ takenBy: "Daniel", scheduleDays: 14 }],
},
} as const;
// =============================================================================
// Helper Functions
// =============================================================================
@@ -278,239 +345,251 @@ export async function exportRoutes(app: FastifyInstance) {
// ---------------------------------------------------------------------------
// GET /export - Export all user data
// ---------------------------------------------------------------------------
app.get<{ Querystring: { includeSensitive?: string; includeImages?: string } }>("/export", async (request, reply) => {
const userId = await getUserId(request, reply);
const includeSensitive = request.query.includeSensitive === "true";
const includeImages = request.query.includeImages !== "false"; // Default to true
// 1. Load all medications
const meds = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
// Build medication ID to export ID mapping
const medIdToExportId = new Map<number, string>();
const exportMedications = meds.map((med, index) => {
const exportId = `med-${index + 1}`;
medIdToExportId.set(med.id, exportId);
// Safely convert lastStockCorrectionAt to ISO string
let lastStockCorrectionAtIso: string | null = null;
if (med.lastStockCorrectionAt) {
try {
if (med.lastStockCorrectionAt instanceof Date && !Number.isNaN(med.lastStockCorrectionAt.getTime())) {
lastStockCorrectionAtIso = med.lastStockCorrectionAt.toISOString();
} else if (typeof med.lastStockCorrectionAt === "number" || typeof med.lastStockCorrectionAt === "string") {
const d = new Date(med.lastStockCorrectionAt);
lastStockCorrectionAtIso = !Number.isNaN(d.getTime()) ? d.toISOString() : null;
}
} catch {
lastStockCorrectionAtIso = null;
}
}
return {
_exportId: exportId,
name: med.name,
genericName: med.genericName,
takenBy: parseTakenByJson(med.takenByJson),
medicationForm: med.medicationForm ?? "tablet",
pillForm: med.pillForm ?? null,
lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
inventory: {
packCount: med.packCount ?? 1,
blistersPerPack: med.blistersPerPack ?? 1,
pillsPerBlister: med.pillsPerBlister ?? 1,
totalPills: med.totalPills ?? null,
looseTablets: med.looseTablets ?? 0,
stockAdjustment: med.stockAdjustment ?? 0,
packageType: normalizePackageType(med.packageType),
packageAmountValue: med.packageAmountValue ?? 0,
packageAmountUnit: (med.packageAmountUnit ?? "ml") as "ml" | "g",
app.get<{ Querystring: { includeSensitive?: string; includeImages?: string } }>(
"/export",
{
schema: {
querystring: exportQuerystringSchema,
response: {
200: exportResponseSchema,
401: genericErrorSchema,
},
pillWeightMg: med.pillWeightMg,
doseUnit: med.doseUnit ?? "mg",
schedules: parseIntakesForExport(med),
medicationStartDate: med.medicationStartDate || null,
medicationEndDate: med.medicationEndDate || null,
autoMarkObsoleteAfterEndDate: med.autoMarkObsoleteAfterEndDate ?? true,
expiryDate: med.expiryDate,
notes: med.notes,
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
isObsolete: med.isObsolete ?? false,
obsoleteAt: med.obsoleteAt?.toISOString() ?? null,
prescriptionEnabled: med.prescriptionEnabled ?? false,
prescriptionAuthorizedRefills: med.prescriptionAuthorizedRefills ?? null,
prescriptionRemainingRefills: med.prescriptionRemainingRefills ?? null,
prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
prescriptionExpiryDate: med.prescriptionExpiryDate ?? null,
dismissedUntil: med.dismissedUntil ?? null,
image: includeImages ? imageToBase64(med.imageUrl) : null,
lastStockCorrectionAt: lastStockCorrectionAtIso,
};
});
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
const includeSensitive = request.query.includeSensitive === "true";
const includeImages = request.query.includeImages !== "false"; // Default to true
// 2. Load all dose tracking entries
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
// 1. Load all medications
const meds = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
const exportDoseHistory = doses
.map((dose) => {
const parsed = parseDoseId(dose.doseId);
if (!parsed) return null;
// Build medication ID to export ID mapping
const medIdToExportId = new Map<number, string>();
const exportMedications = meds.map((med, index) => {
const exportId = `med-${index + 1}`;
medIdToExportId.set(med.id, exportId);
const exportId = medIdToExportId.get(parsed.medicationId);
if (!exportId) return null; // Orphaned dose, skip
// Safely convert lastStockCorrectionAt to ISO string
let lastStockCorrectionAtIso: string | null = null;
if (med.lastStockCorrectionAt) {
try {
if (med.lastStockCorrectionAt instanceof Date && !Number.isNaN(med.lastStockCorrectionAt.getTime())) {
lastStockCorrectionAtIso = med.lastStockCorrectionAt.toISOString();
} else if (typeof med.lastStockCorrectionAt === "number" || typeof med.lastStockCorrectionAt === "string") {
const d = new Date(med.lastStockCorrectionAt);
lastStockCorrectionAtIso = !Number.isNaN(d.getTime()) ? d.toISOString() : null;
}
} catch {
lastStockCorrectionAtIso = null;
}
}
// Safely convert takenAt to ISO string
let takenAtIso: string;
try {
if (dose.takenAt instanceof Date && !Number.isNaN(dose.takenAt.getTime())) {
takenAtIso = dose.takenAt.toISOString();
} else if (typeof dose.takenAt === "number" || typeof dose.takenAt === "string") {
const d = new Date(dose.takenAt);
takenAtIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
} else {
return {
_exportId: exportId,
name: med.name,
genericName: med.genericName,
takenBy: parseTakenByJson(med.takenByJson),
medicationForm: med.medicationForm ?? "tablet",
pillForm: med.pillForm ?? null,
lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
inventory: {
packCount: med.packCount ?? 1,
blistersPerPack: med.blistersPerPack ?? 1,
pillsPerBlister: med.pillsPerBlister ?? 1,
totalPills: med.totalPills ?? null,
looseTablets: med.looseTablets ?? 0,
stockAdjustment: med.stockAdjustment ?? 0,
packageType: normalizePackageType(med.packageType),
packageAmountValue: med.packageAmountValue ?? 0,
packageAmountUnit: (med.packageAmountUnit ?? "ml") as "ml" | "g",
},
pillWeightMg: med.pillWeightMg,
doseUnit: med.doseUnit ?? "mg",
schedules: parseIntakesForExport(med),
medicationStartDate: med.medicationStartDate || null,
medicationEndDate: med.medicationEndDate || null,
autoMarkObsoleteAfterEndDate: med.autoMarkObsoleteAfterEndDate ?? true,
expiryDate: med.expiryDate,
notes: med.notes,
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
isObsolete: med.isObsolete ?? false,
obsoleteAt: med.obsoleteAt?.toISOString() ?? null,
prescriptionEnabled: med.prescriptionEnabled ?? false,
prescriptionAuthorizedRefills: med.prescriptionAuthorizedRefills ?? null,
prescriptionRemainingRefills: med.prescriptionRemainingRefills ?? null,
prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
prescriptionExpiryDate: med.prescriptionExpiryDate ?? null,
dismissedUntil: med.dismissedUntil ?? null,
image: includeImages ? imageToBase64(med.imageUrl) : null,
lastStockCorrectionAt: lastStockCorrectionAtIso,
};
});
// 2. Load all dose tracking entries
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
const exportDoseHistory = doses
.map((dose) => {
const parsed = parseDoseId(dose.doseId);
if (!parsed) return null;
const exportId = medIdToExportId.get(parsed.medicationId);
if (!exportId) return null; // Orphaned dose, skip
// Safely convert takenAt to ISO string
let takenAtIso: string;
try {
if (dose.takenAt instanceof Date && !Number.isNaN(dose.takenAt.getTime())) {
takenAtIso = dose.takenAt.toISOString();
} else if (typeof dose.takenAt === "number" || typeof dose.takenAt === "string") {
const d = new Date(dose.takenAt);
takenAtIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
} else {
takenAtIso = new Date().toISOString();
}
} catch {
takenAtIso = new Date().toISOString();
}
} catch {
takenAtIso = new Date().toISOString();
}
// Safely convert scheduled time
let scheduledTimeIso: string;
try {
const d = new Date(parsed.timestampMs);
scheduledTimeIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
} catch {
scheduledTimeIso = new Date().toISOString();
// Safely convert scheduled time
let scheduledTimeIso: string;
try {
const d = new Date(parsed.timestampMs);
scheduledTimeIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
} catch {
scheduledTimeIso = new Date().toISOString();
}
return {
medicationRef: exportId,
scheduleIndex: parsed.blisterIndex,
scheduledTime: scheduledTimeIso,
takenAt: takenAtIso,
markedBy: dose.markedBy,
takenSource: dose.takenSource === "automatic" ? "automatic" : "manual",
dismissed: dose.dismissed ?? false,
takenByPerson: parsed.person,
};
})
.filter((d): d is NonNullable<typeof d> => d !== null);
// 3. Load user settings
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
const exportSettings = settings
? {
emailEnabled: settings.emailEnabled,
notificationEmail: settings.notificationEmail,
emailStockReminders: settings.emailStockReminders,
emailIntakeReminders: settings.emailIntakeReminders,
emailPrescriptionReminders: settings.emailPrescriptionReminders ?? true,
// Only include sensitive data if requested
shoutrrrEnabled: includeSensitive ? settings.shoutrrrEnabled : undefined,
shoutrrrUrl: includeSensitive ? settings.shoutrrrUrl : undefined,
shoutrrrStockReminders: settings.shoutrrrStockReminders,
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
shoutrrrPrescriptionReminders: settings.shoutrrrPrescriptionReminders ?? true,
reminderDaysBefore: settings.reminderDaysBefore,
repeatDailyReminders: settings.repeatDailyReminders,
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses,
repeatRemindersEnabled: settings.repeatRemindersEnabled,
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes,
maxNaggingReminders: settings.maxNaggingReminders,
lowStockDays: settings.lowStockDays,
normalStockDays: settings.normalStockDays,
highStockDays: settings.highStockDays,
expiryWarningDays: settings.expiryWarningDays,
language: settings.language,
stockCalculationMode: settings.stockCalculationMode,
shareStockStatus: settings.shareStockStatus,
}
: undefined;
// 4. Load share links
const shares = await db.select().from(shareTokens).where(eq(shareTokens.userId, userId));
const exportShareLinks = shares.map((share) => {
// Safely convert expiresAt to ISO string
let expiresAtIso: string | null = null;
if (share.expiresAt) {
try {
if (share.expiresAt instanceof Date && !Number.isNaN(share.expiresAt.getTime())) {
expiresAtIso = share.expiresAt.toISOString();
} else if (typeof share.expiresAt === "number" || typeof share.expiresAt === "string") {
const d = new Date(share.expiresAt);
expiresAtIso = !Number.isNaN(d.getTime()) ? d.toISOString() : null;
}
} catch {
expiresAtIso = null;
}
}
return {
medicationRef: exportId,
scheduleIndex: parsed.blisterIndex,
scheduledTime: scheduledTimeIso,
takenAt: takenAtIso,
markedBy: dose.markedBy,
takenSource: dose.takenSource === "automatic" ? "automatic" : "manual",
dismissed: dose.dismissed ?? false,
takenByPerson: parsed.person,
takenBy: share.takenBy,
scheduleDays: share.scheduleDays,
expiresAt: expiresAtIso,
regenerateToken: true, // Always regenerate tokens on import for security
};
})
.filter((d): d is NonNullable<typeof d> => d !== null);
});
// 3. Load user settings
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
// 5. Load refill history
const refills = await db.select().from(refillHistory).where(eq(refillHistory.userId, userId));
const exportSettings = settings
? {
emailEnabled: settings.emailEnabled,
notificationEmail: settings.notificationEmail,
emailStockReminders: settings.emailStockReminders,
emailIntakeReminders: settings.emailIntakeReminders,
emailPrescriptionReminders: settings.emailPrescriptionReminders ?? true,
// Only include sensitive data if requested
shoutrrrEnabled: includeSensitive ? settings.shoutrrrEnabled : undefined,
shoutrrrUrl: includeSensitive ? settings.shoutrrrUrl : undefined,
shoutrrrStockReminders: settings.shoutrrrStockReminders,
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
shoutrrrPrescriptionReminders: settings.shoutrrrPrescriptionReminders ?? true,
reminderDaysBefore: settings.reminderDaysBefore,
repeatDailyReminders: settings.repeatDailyReminders,
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses,
repeatRemindersEnabled: settings.repeatRemindersEnabled,
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes,
maxNaggingReminders: settings.maxNaggingReminders,
lowStockDays: settings.lowStockDays,
normalStockDays: settings.normalStockDays,
highStockDays: settings.highStockDays,
expiryWarningDays: settings.expiryWarningDays,
language: settings.language,
stockCalculationMode: settings.stockCalculationMode,
shareStockStatus: settings.shareStockStatus,
}
: undefined;
const exportRefillHistory = refills
.map((refill) => {
const exportId = medIdToExportId.get(refill.medicationId);
if (!exportId) return null; // Orphaned refill, skip
// 4. Load share links
const shares = await db.select().from(shareTokens).where(eq(shareTokens.userId, userId));
const exportShareLinks = shares.map((share) => {
// Safely convert expiresAt to ISO string
let expiresAtIso: string | null = null;
if (share.expiresAt) {
try {
if (share.expiresAt instanceof Date && !Number.isNaN(share.expiresAt.getTime())) {
expiresAtIso = share.expiresAt.toISOString();
} else if (typeof share.expiresAt === "number" || typeof share.expiresAt === "string") {
const d = new Date(share.expiresAt);
expiresAtIso = !Number.isNaN(d.getTime()) ? d.toISOString() : null;
}
} catch {
expiresAtIso = null;
}
}
return {
takenBy: share.takenBy,
scheduleDays: share.scheduleDays,
expiresAt: expiresAtIso,
regenerateToken: true, // Always regenerate tokens on import for security
};
});
// 5. Load refill history
const refills = await db.select().from(refillHistory).where(eq(refillHistory.userId, userId));
const exportRefillHistory = refills
.map((refill) => {
const exportId = medIdToExportId.get(refill.medicationId);
if (!exportId) return null; // Orphaned refill, skip
// Safely convert refillDate to ISO string
let refillDateIso: string;
try {
if (refill.refillDate instanceof Date && !Number.isNaN(refill.refillDate.getTime())) {
refillDateIso = refill.refillDate.toISOString();
} else if (typeof refill.refillDate === "number" || typeof refill.refillDate === "string") {
const d = new Date(refill.refillDate);
refillDateIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
} else {
// Safely convert refillDate to ISO string
let refillDateIso: string;
try {
if (refill.refillDate instanceof Date && !Number.isNaN(refill.refillDate.getTime())) {
refillDateIso = refill.refillDate.toISOString();
} else if (typeof refill.refillDate === "number" || typeof refill.refillDate === "string") {
const d = new Date(refill.refillDate);
refillDateIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
} else {
refillDateIso = new Date().toISOString();
}
} catch {
refillDateIso = new Date().toISOString();
}
} catch {
refillDateIso = new Date().toISOString();
}
return {
medicationRef: exportId,
packsAdded: refill.packsAdded ?? 0,
loosePillsAdded: refill.loosePillsAdded ?? 0,
usedPrescription: refill.usedPrescription ?? false,
refillDate: refillDateIso,
};
})
.filter((r): r is NonNullable<typeof r> => r !== null);
return {
medicationRef: exportId,
packsAdded: refill.packsAdded ?? 0,
loosePillsAdded: refill.loosePillsAdded ?? 0,
usedPrescription: refill.usedPrescription ?? false,
refillDate: refillDateIso,
};
})
.filter((r): r is NonNullable<typeof r> => r !== null);
// Build export object
const exportData = {
version: EXPORT_VERSION,
exportedAt: new Date().toISOString(),
includeSensitiveData: includeSensitive,
medications: exportMedications,
doseHistory: exportDoseHistory,
refillHistory: exportRefillHistory,
settings: exportSettings,
shareLinks: exportShareLinks,
};
// Build export object
const exportData = {
version: EXPORT_VERSION,
exportedAt: new Date().toISOString(),
includeSensitiveData: includeSensitive,
medications: exportMedications,
doseHistory: exportDoseHistory,
refillHistory: exportRefillHistory,
settings: exportSettings,
shareLinks: exportShareLinks,
};
// Set download headers
const now = new Date();
const dateStr = now.toISOString().replace(/[-:]/g, "").replace(/T/, "-").slice(0, 13);
const authUser = env.AUTH_ENABLED ? (request.user as unknown as AuthUser | null) : null;
const userPart = authUser?.username ? `-${authUser.username}` : "";
const filename = `medassist-export${userPart}-${dateStr}.json`;
reply.header("Content-Type", "application/json");
reply.header("Content-Disposition", `attachment; filename="${filename}"`);
// Set download headers
const now = new Date();
const dateStr = now.toISOString().replace(/[-:]/g, "").replace(/T/, "-").slice(0, 13);
const authUser = env.AUTH_ENABLED ? (request.user as unknown as AuthUser | null) : null;
const userPart = authUser?.username ? `-${authUser.username}` : "";
const filename = `medassist-export${userPart}-${dateStr}.json`;
reply.header("Content-Type", "application/json");
reply.header("Content-Disposition", `attachment; filename="${filename}"`);
return exportData;
});
return exportData;
}
);
// ---------------------------------------------------------------------------
// POST /import - Import user data (replaces all existing data!)
@@ -523,6 +602,29 @@ export async function exportRoutes(app: FastifyInstance) {
rawBody: true,
},
bodyLimit: 50 * 1024 * 1024, // 50 MB
schema: {
body: importBodyOpenApiSchema,
response: {
200: {
type: "object",
properties: {
success: { type: "boolean" },
imported: {
type: "object",
properties: {
medications: { type: "integer" },
doseHistory: { type: "integer" },
refillHistory: { type: "integer" },
settings: { type: "integer" },
shareLinks: { type: "integer" },
},
},
},
},
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
+24 -5
View File
@@ -14,9 +14,28 @@ export async function healthRoutes(app: FastifyInstance) {
applyOpenApiRouteStandards(app, { tag: "health", protectedByDefault: false });
// Exempt from rate limit + suppress request logs (called every 30s by Docker healthcheck)
app.get("/health", { config: { rateLimit: false }, logLevel: "warn" }, async () => ({
status: "ok",
version: backendVersion,
smtpConfigured: Boolean(process.env.SMTP_HOST),
}));
app.get(
"/health",
{
config: { rateLimit: false },
logLevel: "warn",
schema: {
response: {
200: {
type: "object",
properties: {
status: { type: "string", enum: ["ok"] },
version: { type: "string" },
smtpConfigured: { type: "boolean" },
},
},
},
},
},
async () => ({
status: "ok",
version: backendVersion,
smtpConfigured: Boolean(process.env.SMTP_HOST),
})
);
}
File diff suppressed because it is too large Load Diff
+69 -42
View File
@@ -5,7 +5,7 @@ import * as client from "openid-client";
import { db } from "../db/client.js";
import { refreshTokens, users } from "../db/schema.js";
import { env } from "../plugins/env.js";
import { applyOpenApiRouteStandards } from "../utils/openapi-route-standards.js";
import { applyOpenApiRouteStandards, genericErrorSchema } from "../utils/openapi-route-standards.js";
// =============================================================================
// OIDC Configuration Cache
@@ -54,10 +54,10 @@ export async function oidcRoutes(app: FastifyInstance) {
if (!env.OIDC_ENABLED) {
// Register a disabled route that returns an error
app.get("/auth/oidc/login", async (_request, reply) => {
app.get("/auth/oidc/login", { schema: { response: { 400: genericErrorSchema } } }, async (_request, reply) => {
return reply.status(400).send({ error: "OIDC authentication is not enabled" });
});
app.get("/auth/oidc/callback", async (_request, reply) => {
app.get("/auth/oidc/callback", { schema: { response: { 400: genericErrorSchema } } }, async (_request, reply) => {
return reply.status(400).send({ error: "OIDC authentication is not enabled" });
});
return;
@@ -66,58 +66,85 @@ export async function oidcRoutes(app: FastifyInstance) {
// ---------------------------------------------------------------------------
// GET /auth/oidc/login - Initiates OIDC flow
// ---------------------------------------------------------------------------
app.get("/auth/oidc/login", async (request, reply) => {
try {
const config = await getOIDCConfig();
app.get(
"/auth/oidc/login",
{
schema: {
response: {
302: { type: "null", description: "Redirect to OIDC provider" },
500: genericErrorSchema,
},
},
},
async (request, reply) => {
try {
const config = await getOIDCConfig();
// Generate PKCE values
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
const state = generateState();
// Generate PKCE values
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
const state = generateState();
// Store PKCE verifier and state in signed cookies (short-lived)
reply.setCookie("oidc_code_verifier", codeVerifier, {
httpOnly: true,
secure: env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 600, // 10 minutes
signed: true,
});
// Store PKCE verifier and state in signed cookies (short-lived)
reply.setCookie("oidc_code_verifier", codeVerifier, {
httpOnly: true,
secure: env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 600, // 10 minutes
signed: true,
});
reply.setCookie("oidc_state", state, {
httpOnly: true,
secure: env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 600,
signed: true,
});
reply.setCookie("oidc_state", state, {
httpOnly: true,
secure: env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 600,
signed: true,
});
// Build authorization URL
const redirectUri = env.OIDC_REDIRECT_URI!;
const scope = env.OIDC_SCOPES;
// Build authorization URL
const redirectUri = env.OIDC_REDIRECT_URI!;
const scope = env.OIDC_SCOPES;
const authUrl = client.buildAuthorizationUrl(config, {
redirect_uri: redirectUri,
scope,
state,
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
const authUrl = client.buildAuthorizationUrl(config, {
redirect_uri: redirectUri,
scope,
state,
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
return reply.redirect(authUrl.href);
} catch (err: unknown) {
request.log.error({ err }, "[OIDC] Login initialization failed");
return reply.redirect(`${getFrontendUrl()}/?error=oidc_init_failed`);
return reply.redirect(authUrl.href);
} catch (err: unknown) {
request.log.error({ err }, "[OIDC] Login initialization failed");
return reply.redirect(`${getFrontendUrl()}/?error=oidc_init_failed`);
}
}
});
);
// ---------------------------------------------------------------------------
// GET /auth/oidc/callback - Handles callback from OIDC provider
// ---------------------------------------------------------------------------
app.get<{ Querystring: { code?: string; state?: string; error?: string; error_description?: string } }>(
"/auth/oidc/callback",
{
schema: {
querystring: {
type: "object",
properties: {
code: { type: "string" },
state: { type: "string" },
error: { type: "string" },
error_description: { type: "string" },
},
},
response: {
302: { type: "null", description: "Redirect back to frontend" },
},
},
},
async (request, reply) => {
const { code, state, error, error_description } = request.query;
File diff suppressed because it is too large Load Diff
+272 -175
View File
@@ -6,7 +6,12 @@ import { medications, refillHistory } 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 { applyOpenApiRouteStandards } from "../utils/openapi-route-standards.js";
import {
applyOpenApiRouteStandards,
genericErrorSchema,
idParamsSchema,
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
import { isAmountBasedPackageType, normalizePackageType } from "../utils/package-profiles.js";
const refillSchema = z
@@ -19,6 +24,68 @@ const refillSchema = z
message: "Must add at least one pack or some loose pills",
});
const refillBodyOpenApiSchema = {
type: "object",
properties: {
packsAdded: { type: "integer", minimum: 0, default: 0 },
loosePillsAdded: { type: "integer", minimum: 0, default: 0 },
usePrescription: { type: "boolean", default: false },
},
description: "Provide at least one pack or some loose pills.",
example: {
packsAdded: 1,
loosePillsAdded: 4,
usePrescription: true,
},
} as const;
const refillResponseSchema = {
type: "object",
properties: {
success: { type: "boolean" },
refill: {
type: "object",
properties: {
id: { type: "number" },
packsAdded: { type: "integer" },
loosePillsAdded: { type: "integer" },
totalPillsAdded: { type: "number" },
refillDate: { type: "string", format: "date-time" },
},
},
newStock: {
type: "object",
properties: {
packCount: { type: "integer" },
looseTablets: { type: "integer" },
totalPills: { type: "number" },
},
},
prescription: {
type: "object",
properties: {
used: { type: "boolean" },
remainingRefills: { type: "integer" },
authorizedRefills: { type: "integer" },
lowRefillThreshold: { type: "integer" },
enabled: { type: "boolean" },
},
},
},
} as const;
const refillHistoryItemSchema = {
type: "object",
properties: {
id: { type: "number" },
packsAdded: { type: "integer" },
loosePillsAdded: { type: "integer" },
totalPillsAdded: { type: "number" },
usedPrescription: { type: "boolean" },
refillDate: { type: "string", format: "date-time" },
},
} as const;
export async function refillRoutes(app: FastifyInstance) {
// All refill routes require auth
app.addHook("preHandler", requireAuth);
@@ -38,188 +105,218 @@ export async function refillRoutes(app: FastifyInstance) {
}
// POST /medications/:id/refill - Add stock to medication
app.post<{ Params: { id: string } }>("/medications/:id/refill", async (req, reply) => {
const parsed = refillSchema.safeParse(req.body);
if (!parsed.success) return reply.status(400).send(parsed.error.format());
const medId = Number(req.params.id);
if (Number.isNaN(medId)) return reply.badRequest("Invalid medication id");
const userId = await getUserId(req, reply);
// Verify ownership
const [med] = await db
.select()
.from(medications)
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
if (!med) return reply.notFound("Medication not found");
const { packsAdded, loosePillsAdded, usePrescription } = parsed.data;
const packageType = normalizePackageType(med.packageType);
const isBottle = packageType === "bottle";
const isAmountBased = isAmountBasedPackageType(packageType);
const isCountBasedAmountPackage = isAmountBased && !isBottle;
const configuredAmountPerPackage = Number(med.packageAmountValue ?? 0);
const fallbackAmountPerPackage = Math.max(
1,
Math.round((med.totalPills ?? med.looseTablets ?? 0) / Math.max(1, med.packCount || 1))
);
const amountPerPackage =
Number.isFinite(configuredAmountPerPackage) && configuredAmountPerPackage > 0
? configuredAmountPerPackage
: fallbackAmountPerPackage;
const requestedPackAdds = Math.max(0, packsAdded);
const requestedAmountAdds = Math.max(0, loosePillsAdded);
const derivedCountFromAmount = Math.max(0, Math.round(requestedAmountAdds / amountPerPackage));
let effectivePacksAdded = requestedPackAdds;
if (isBottle) {
effectivePacksAdded = 0;
} else if (isCountBasedAmountPackage) {
effectivePacksAdded = Math.max(requestedPackAdds, derivedCountFromAmount);
}
const effectiveLoosePillsAdded = isCountBasedAmountPackage
? effectivePacksAdded * amountPerPackage
: requestedAmountAdds;
const remainingPrescriptionRefills = med.prescriptionRemainingRefills ?? 0;
if (effectivePacksAdded < 1 && effectiveLoosePillsAdded < 1) {
return reply.status(400).send({ error: "Must add at least one pack or some loose pills" });
}
if (usePrescription) {
if (!(med.prescriptionEnabled ?? false)) {
return reply.status(400).send({ error: "Prescription refill is not enabled for this medication" });
}
if (remainingPrescriptionRefills <= 0) {
return reply.status(409).send({ error: "No remaining prescription refills" });
}
if (!isBottle && effectivePacksAdded > remainingPrescriptionRefills) {
return reply.status(409).send({ error: "Packs to add exceed remaining prescription refills" });
}
}
// Update medication stock
const newPackCount = med.packCount + effectivePacksAdded;
const newLooseTablets = med.looseTablets + effectiveLoosePillsAdded;
const previousAmountBase = med.totalPills ?? med.looseTablets;
const newTotalAmount = previousAmountBase + effectiveLoosePillsAdded;
let consumedRefills = 0;
if (usePrescription) {
consumedRefills = isBottle ? 1 : effectivePacksAdded;
}
const newRemainingRefills = usePrescription
? Math.max(0, remainingPrescriptionRefills - consumedRefills)
: (med.prescriptionRemainingRefills ?? null);
const updatePayload: {
packCount: number;
looseTablets: number;
totalPills?: number;
packageAmountValue?: number;
prescriptionRemainingRefills: number | null;
updatedAt: Date;
} = {
packCount: newPackCount,
looseTablets: newLooseTablets,
prescriptionRemainingRefills: newRemainingRefills,
updatedAt: new Date(),
};
if (isCountBasedAmountPackage) {
updatePayload.totalPills = newTotalAmount;
updatePayload.packageAmountValue = amountPerPackage;
}
await db
.update(medications)
.set(updatePayload)
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
// Create refill history entry
const [refill] = await db
.insert(refillHistory)
.values({
medicationId: medId,
userId,
packsAdded: effectivePacksAdded,
loosePillsAdded: effectiveLoosePillsAdded,
usedPrescription: usePrescription,
})
.returning();
// Calculate pills added for response (packageType-aware)
const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
const totalPillsAdded = isAmountBased
? effectiveLoosePillsAdded
: effectivePacksAdded * pillsPerPack + effectiveLoosePillsAdded;
let newTotalPills = newPackCount * pillsPerPack + newLooseTablets + (med.stockAdjustment ?? 0);
if (isCountBasedAmountPackage) {
newTotalPills = (newTotalAmount ?? 0) + (med.stockAdjustment ?? 0);
} else if (isBottle) {
newTotalPills = newLooseTablets + (med.stockAdjustment ?? 0);
}
return {
success: true,
refill: {
id: refill.id,
packsAdded: effectivePacksAdded,
loosePillsAdded: effectiveLoosePillsAdded,
totalPillsAdded,
refillDate: refill.refillDate,
app.post<{ Params: { id: string } }>(
"/medications/:id/refill",
{
schema: {
params: idParamsSchema,
body: refillBodyOpenApiSchema,
response: {
200: refillResponseSchema,
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
404: genericErrorSchema,
409: genericErrorSchema,
},
},
newStock: {
},
async (req, reply) => {
const parsed = refillSchema.safeParse(req.body);
if (!parsed.success) return reply.status(400).send(parsed.error.format());
const medId = Number(req.params.id);
if (Number.isNaN(medId)) return reply.badRequest("Invalid medication id");
const userId = await getUserId(req, reply);
// Verify ownership
const [med] = await db
.select()
.from(medications)
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
if (!med) return reply.notFound("Medication not found");
const { packsAdded, loosePillsAdded, usePrescription } = parsed.data;
const packageType = normalizePackageType(med.packageType);
const isBottle = packageType === "bottle";
const isAmountBased = isAmountBasedPackageType(packageType);
const isCountBasedAmountPackage = isAmountBased && !isBottle;
const configuredAmountPerPackage = Number(med.packageAmountValue ?? 0);
const fallbackAmountPerPackage = Math.max(
1,
Math.round((med.totalPills ?? med.looseTablets ?? 0) / Math.max(1, med.packCount || 1))
);
const amountPerPackage =
Number.isFinite(configuredAmountPerPackage) && configuredAmountPerPackage > 0
? configuredAmountPerPackage
: fallbackAmountPerPackage;
const requestedPackAdds = Math.max(0, packsAdded);
const requestedAmountAdds = Math.max(0, loosePillsAdded);
const derivedCountFromAmount = Math.max(0, Math.round(requestedAmountAdds / amountPerPackage));
let effectivePacksAdded = requestedPackAdds;
if (isBottle) {
effectivePacksAdded = 0;
} else if (isCountBasedAmountPackage) {
effectivePacksAdded = Math.max(requestedPackAdds, derivedCountFromAmount);
}
const effectiveLoosePillsAdded = isCountBasedAmountPackage
? effectivePacksAdded * amountPerPackage
: requestedAmountAdds;
const remainingPrescriptionRefills = med.prescriptionRemainingRefills ?? 0;
if (effectivePacksAdded < 1 && effectiveLoosePillsAdded < 1) {
return reply.status(400).send({ error: "Must add at least one pack or some loose pills" });
}
if (usePrescription) {
if (!(med.prescriptionEnabled ?? false)) {
return reply.status(400).send({ error: "Prescription refill is not enabled for this medication" });
}
if (remainingPrescriptionRefills <= 0) {
return reply.status(409).send({ error: "No remaining prescription refills" });
}
if (!isBottle && effectivePacksAdded > remainingPrescriptionRefills) {
return reply.status(409).send({ error: "Packs to add exceed remaining prescription refills" });
}
}
// Update medication stock
const newPackCount = med.packCount + effectivePacksAdded;
const newLooseTablets = med.looseTablets + effectiveLoosePillsAdded;
const previousAmountBase = med.totalPills ?? med.looseTablets;
const newTotalAmount = previousAmountBase + effectiveLoosePillsAdded;
let consumedRefills = 0;
if (usePrescription) {
consumedRefills = isBottle ? 1 : effectivePacksAdded;
}
const newRemainingRefills = usePrescription
? Math.max(0, remainingPrescriptionRefills - consumedRefills)
: (med.prescriptionRemainingRefills ?? null);
const updatePayload: {
packCount: number;
looseTablets: number;
totalPills?: number;
packageAmountValue?: number;
prescriptionRemainingRefills: number | null;
updatedAt: Date;
} = {
packCount: newPackCount,
looseTablets: newLooseTablets,
totalPills: newTotalPills,
},
prescription: {
used: usePrescription,
remainingRefills: newRemainingRefills,
authorizedRefills: med.prescriptionAuthorizedRefills ?? null,
lowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
enabled: med.prescriptionEnabled ?? false,
},
};
});
prescriptionRemainingRefills: newRemainingRefills,
updatedAt: new Date(),
};
if (isCountBasedAmountPackage) {
updatePayload.totalPills = newTotalAmount;
updatePayload.packageAmountValue = amountPerPackage;
}
await db
.update(medications)
.set(updatePayload)
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
// Create refill history entry
const [refill] = await db
.insert(refillHistory)
.values({
medicationId: medId,
userId,
packsAdded: effectivePacksAdded,
loosePillsAdded: effectiveLoosePillsAdded,
usedPrescription: usePrescription,
})
.returning();
// Calculate pills added for response (packageType-aware)
const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
const totalPillsAdded = isAmountBased
? effectiveLoosePillsAdded
: effectivePacksAdded * pillsPerPack + effectiveLoosePillsAdded;
let newTotalPills = newPackCount * pillsPerPack + newLooseTablets + (med.stockAdjustment ?? 0);
if (isCountBasedAmountPackage) {
newTotalPills = (newTotalAmount ?? 0) + (med.stockAdjustment ?? 0);
} else if (isBottle) {
newTotalPills = newLooseTablets + (med.stockAdjustment ?? 0);
}
return {
success: true,
refill: {
id: refill.id,
packsAdded: effectivePacksAdded,
loosePillsAdded: effectiveLoosePillsAdded,
totalPillsAdded,
refillDate: refill.refillDate,
},
newStock: {
packCount: newPackCount,
looseTablets: newLooseTablets,
totalPills: newTotalPills,
},
prescription: {
used: usePrescription,
remainingRefills: newRemainingRefills,
authorizedRefills: med.prescriptionAuthorizedRefills ?? null,
lowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
enabled: med.prescriptionEnabled ?? false,
},
};
}
);
// GET /medications/:id/refills - Get refill history for a medication
app.get<{ Params: { id: string } }>("/medications/:id/refills", async (req, reply) => {
const medId = Number(req.params.id);
if (Number.isNaN(medId)) return reply.badRequest("Invalid medication id");
app.get<{ Params: { id: string } }>(
"/medications/:id/refills",
{
schema: {
params: idParamsSchema,
response: {
200: { type: "array", items: refillHistoryItemSchema },
400: genericErrorSchema,
401: genericErrorSchema,
404: genericErrorSchema,
},
},
},
async (req, reply) => {
const medId = Number(req.params.id);
if (Number.isNaN(medId)) return reply.badRequest("Invalid medication id");
const userId = await getUserId(req, reply);
const userId = await getUserId(req, reply);
// Verify ownership
const [med] = await db
.select()
.from(medications)
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
if (!med) return reply.notFound("Medication not found");
// Verify ownership
const [med] = await db
.select()
.from(medications)
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
if (!med) return reply.notFound("Medication not found");
// Get refill history, newest first
const refills = await db
.select()
.from(refillHistory)
.where(and(eq(refillHistory.medicationId, medId), eq(refillHistory.userId, userId)))
.orderBy(desc(refillHistory.refillDate));
// Get refill history, newest first
const refills = await db
.select()
.from(refillHistory)
.where(and(eq(refillHistory.medicationId, medId), eq(refillHistory.userId, userId)))
.orderBy(desc(refillHistory.refillDate));
const packageType = normalizePackageType(med.packageType);
const isBottle = packageType === "bottle";
const isAmountBased = isAmountBasedPackageType(packageType);
const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
const packageType = normalizePackageType(med.packageType);
const isBottle = packageType === "bottle";
const isAmountBased = isAmountBasedPackageType(packageType);
const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
return refills.map((r) => ({
id: r.id,
packsAdded: r.packsAdded,
loosePillsAdded: r.loosePillsAdded,
totalPillsAdded: isAmountBased ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded,
usedPrescription: r.usedPrescription ?? false,
refillDate: r.refillDate,
}));
});
return refills.map((r) => ({
id: r.id,
packsAdded: r.packsAdded,
loosePillsAdded: r.loosePillsAdded,
totalPillsAdded: isAmountBased ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded,
usedPrescription: r.usedPrescription ?? false,
refillDate: r.refillDate,
}));
}
);
}
+135 -75
View File
@@ -6,12 +6,58 @@ import { doseTracking, medications, refillHistory } 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 { applyOpenApiRouteStandards } from "../utils/openapi-route-standards.js";
import {
applyOpenApiRouteStandards,
genericErrorSchema,
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
const reportDataSchema = z.object({
medicationIds: z.array(z.number().int().positive()).min(1).max(100),
});
const reportDataBodyOpenApiSchema = {
type: "object",
required: ["medicationIds"],
properties: {
medicationIds: {
type: "array",
minItems: 1,
maxItems: 100,
items: { type: "integer", minimum: 1 },
},
},
example: {
medicationIds: [1, 3, 5],
},
} as const;
const reportDataResponseSchema = {
type: "object",
additionalProperties: {
type: "object",
properties: {
dosesTaken: { type: "integer" },
automaticDosesTaken: { type: "integer" },
dosesDismissed: { type: "integer" },
firstDoseAt: { type: "string" },
lastDoseAt: { type: "string" },
refills: {
type: "array",
items: {
type: "object",
properties: {
packsAdded: { type: "integer" },
loosePillsAdded: { type: "integer" },
usedPrescription: { type: "boolean" },
refillDate: { type: "string", format: "date-time" },
},
},
},
},
},
} as const;
export async function reportRoutes(app: FastifyInstance) {
app.addHook("preHandler", requireAuth);
applyOpenApiRouteStandards(app, { tag: "report", protectedByDefault: true });
@@ -29,90 +75,104 @@ export async function reportRoutes(app: FastifyInstance) {
}
// POST /medications/report-data - Get aggregated dose/refill data for report generation
app.post("/medications/report-data", async (req, reply) => {
const parsed = reportDataSchema.safeParse(req.body);
if (!parsed.success) return reply.status(400).send(parsed.error.format());
app.post(
"/medications/report-data",
{
schema: {
body: reportDataBodyOpenApiSchema,
response: {
200: reportDataResponseSchema,
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
403: genericErrorSchema,
},
},
},
async (req, reply) => {
const parsed = reportDataSchema.safeParse(req.body);
if (!parsed.success) return reply.status(400).send(parsed.error.format());
const userId = await getUserId(req, reply);
const { medicationIds } = parsed.data;
const userId = await getUserId(req, reply);
const { medicationIds } = parsed.data;
// Verify all medications belong to this user
const userMeds = await db.select({ id: medications.id }).from(medications).where(eq(medications.userId, userId));
const userMedIds = new Set(userMeds.map((m) => m.id));
// Verify all medications belong to this user
const userMeds = await db.select({ id: medications.id }).from(medications).where(eq(medications.userId, userId));
const userMedIds = new Set(userMeds.map((m) => m.id));
for (const id of medicationIds) {
if (!userMedIds.has(id)) {
return reply.status(403).send({ error: "Access denied to medication" });
for (const id of medicationIds) {
if (!userMedIds.has(id)) {
return reply.status(403).send({ error: "Access denied to medication" });
}
}
}
// Fetch dose tracking for all requested medications
// doseId format: "{medicationId}-{blisterIndex}-{dateMs}" or "{medicationId}-{blisterIndex}-{dateMs}-{takenBy}"
const allDoses = await db
.select({
doseId: doseTracking.doseId,
takenAt: doseTracking.takenAt,
dismissed: doseTracking.dismissed,
takenSource: doseTracking.takenSource,
})
.from(doseTracking)
.where(eq(doseTracking.userId, userId));
// Fetch dose tracking for all requested medications
// doseId format: "{medicationId}-{blisterIndex}-{dateMs}" or "{medicationId}-{blisterIndex}-{dateMs}-{takenBy}"
const allDoses = await db
.select({
doseId: doseTracking.doseId,
takenAt: doseTracking.takenAt,
dismissed: doseTracking.dismissed,
takenSource: doseTracking.takenSource,
})
.from(doseTracking)
.where(eq(doseTracking.userId, userId));
// Group doses by medication ID
const dosesByMed = new Map<number, { takenAt: Date; dismissed: boolean; takenSource: string }[]>();
for (const dose of allDoses) {
const medId = Number.parseInt(dose.doseId.split("-")[0], 10);
if (Number.isNaN(medId) || !medicationIds.includes(medId)) continue;
if (!dosesByMed.has(medId)) dosesByMed.set(medId, []);
dosesByMed.get(medId)!.push({
takenAt: dose.takenAt,
dismissed: dose.dismissed,
takenSource: dose.takenSource ?? "manual",
});
}
// Fetch refill history for requested medications
const result: Record<
number,
{
dosesTaken: number;
automaticDosesTaken: number;
dosesDismissed: number;
firstDoseAt: string | null;
lastDoseAt: string | null;
refills: { packsAdded: number; loosePillsAdded: number; usedPrescription: boolean; refillDate: string }[];
// Group doses by medication ID
const dosesByMed = new Map<number, { takenAt: Date; dismissed: boolean; takenSource: string }[]>();
for (const dose of allDoses) {
const medId = Number.parseInt(dose.doseId.split("-")[0], 10);
if (Number.isNaN(medId) || !medicationIds.includes(medId)) continue;
if (!dosesByMed.has(medId)) dosesByMed.set(medId, []);
dosesByMed.get(medId)!.push({
takenAt: dose.takenAt,
dismissed: dose.dismissed,
takenSource: dose.takenSource ?? "manual",
});
}
> = {};
for (const medId of medicationIds) {
const doses = dosesByMed.get(medId) ?? [];
const takenDoses = doses.filter((d) => !d.dismissed);
const automaticTakenDoses = takenDoses.filter((d) => d.takenSource === "automatic");
const dismissedDoses = doses.filter((d) => d.dismissed);
// Fetch refill history for requested medications
const result: Record<
number,
{
dosesTaken: number;
automaticDosesTaken: number;
dosesDismissed: number;
firstDoseAt: string | null;
lastDoseAt: string | null;
refills: { packsAdded: number; loosePillsAdded: number; usedPrescription: boolean; refillDate: string }[];
}
> = {};
const sortedTaken = takenDoses.map((d) => d.takenAt.getTime()).sort((a, b) => a - b);
for (const medId of medicationIds) {
const doses = dosesByMed.get(medId) ?? [];
const takenDoses = doses.filter((d) => !d.dismissed);
const automaticTakenDoses = takenDoses.filter((d) => d.takenSource === "automatic");
const dismissedDoses = doses.filter((d) => d.dismissed);
// Get refills for this medication scoped to the authenticated user.
const refills = await db
.select()
.from(refillHistory)
.where(and(eq(refillHistory.medicationId, medId), eq(refillHistory.userId, userId)));
const sortedTaken = takenDoses.map((d) => d.takenAt.getTime()).sort((a, b) => a - b);
result[medId] = {
dosesTaken: takenDoses.length,
automaticDosesTaken: automaticTakenDoses.length,
dosesDismissed: dismissedDoses.length,
firstDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[0]).toISOString() : null,
lastDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[sortedTaken.length - 1]).toISOString() : null,
refills: refills.map((r) => ({
packsAdded: r.packsAdded,
loosePillsAdded: r.loosePillsAdded,
usedPrescription: r.usedPrescription ?? false,
refillDate: r.refillDate instanceof Date ? r.refillDate.toISOString() : String(r.refillDate),
})),
};
// Get refills for this medication scoped to the authenticated user.
const refills = await db
.select()
.from(refillHistory)
.where(and(eq(refillHistory.medicationId, medId), eq(refillHistory.userId, userId)));
result[medId] = {
dosesTaken: takenDoses.length,
automaticDosesTaken: automaticTakenDoses.length,
dosesDismissed: dismissedDoses.length,
firstDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[0]).toISOString() : null,
lastDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[sortedTaken.length - 1]).toISOString() : null,
refills: refills.map((r) => ({
packsAdded: r.packsAdded,
loosePillsAdded: r.loosePillsAdded,
usedPrescription: r.usedPrescription ?? false,
refillDate: r.refillDate instanceof Date ? r.refillDate.toISOString() : String(r.refillDate),
})),
};
}
return result;
}
return result;
});
);
}
+36
View File
@@ -481,6 +481,33 @@ export async function settingsRoutes(app: FastifyInstance) {
shareScheduleTodayOnly: { type: "boolean" },
swapDashboardMainSections: { type: "boolean" },
},
example: {
emailEnabled: true,
notificationEmail: "daniel@example.com",
reminderDaysBefore: 7,
repeatDailyReminders: true,
lowStockDays: 14,
normalStockDays: 30,
highStockDays: 90,
shoutrrrEnabled: false,
shoutrrrUrl: "",
emailStockReminders: true,
emailIntakeReminders: true,
emailPrescriptionReminders: true,
shoutrrrStockReminders: false,
shoutrrrIntakeReminders: false,
shoutrrrPrescriptionReminders: false,
skipRemindersForTakenDoses: true,
repeatRemindersEnabled: true,
reminderRepeatIntervalMinutes: 30,
maxNaggingReminders: 5,
language: "en",
stockCalculationMode: "automatic",
shareStockStatus: true,
upcomingTodayOnly: false,
shareScheduleTodayOnly: false,
swapDashboardMainSections: false,
},
},
response: {
200: { type: "object", properties: { success: { type: "boolean" } } },
@@ -560,6 +587,9 @@ export async function settingsRoutes(app: FastifyInstance) {
properties: {
language: { type: "string", enum: ["en", "de"] },
},
example: {
language: "de",
},
},
response: {
200: { type: "object", properties: { success: { type: "boolean" } } },
@@ -607,6 +637,9 @@ export async function settingsRoutes(app: FastifyInstance) {
properties: {
email: { type: "string", format: "email" },
},
example: {
email: "daniel@example.com",
},
},
response: {
200: {
@@ -714,6 +747,9 @@ export async function settingsRoutes(app: FastifyInstance) {
properties: {
url: { type: "string" },
},
example: {
url: "ntfy://user:token@push.example.com/medassist",
},
},
response: {
200: {
+115 -3
View File
@@ -8,7 +8,12 @@ import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import { buildSharedMedicationOverview } from "../services/coverage.js";
import type { AuthUser } from "../types/fastify.js";
import { applyOpenApiRouteStandards } from "../utils/openapi-route-standards.js";
import {
applyOpenApiRouteStandards,
genericErrorSchema,
tokenParamsSchema,
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
import { isAmountBasedPackageType, normalizePackageType } from "../utils/package-profiles.js";
import {
getAllTakenByForMedication,
@@ -32,6 +37,62 @@ const protectedEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>
const shareTokenPattern = /^[a-f0-9]{16}$/;
const createShareBodyOpenApiSchema = {
type: "object",
properties: {
takenBy: { type: "string" },
scheduleDays: { type: "integer", minimum: 1, maximum: 365, default: 30 },
},
example: {
takenBy: "Daniel",
scheduleDays: 14,
},
} as const;
const shareReadResponseSchema = {
type: "object",
properties: {
takenBy: { type: "string" },
sharedBy: { type: "string" },
scheduleDays: { type: "integer" },
medications: { type: "array", items: { type: "object", additionalProperties: true } },
stockThresholds: { type: "object", additionalProperties: { type: "number" } },
stockCalculationMode: { type: "string", enum: ["automatic", "manual"] },
shareStockStatus: { type: "boolean" },
upcomingTodayOnly: { type: "boolean" },
shareScheduleTodayOnly: { type: "boolean" },
},
} as const;
const shareExpiredResponseSchema = {
type: "object",
properties: {
error: { type: "string" },
code: { type: "string" },
ownerUsername: { type: "string" },
takenBy: { type: "string" },
expiredAt: { type: "string", format: "date-time" },
},
} as const;
const shareOverviewExpiredResponseSchema = {
type: "object",
properties: {
error: { type: "string" },
expiredAt: { type: "string", format: "date-time" },
},
} as const;
const shareOverviewResponseSchema = {
type: "object",
properties: {
takenBy: { type: "string" },
sharedBy: { type: "string" },
generatedAt: { type: "string", format: "date-time" },
medications: { type: "array", items: { type: "object", additionalProperties: true } },
},
} as const;
function maskToken(token: string): string {
if (token.length <= 8) return token;
return `${token.slice(0, 4)}...${token.slice(-4)}`;
@@ -69,6 +130,14 @@ export async function shareRoutes(app: FastifyInstance) {
app.get<{ Params: { token: string } }>(
"/share/:token",
{
schema: {
params: tokenParamsSchema,
response: {
200: shareReadResponseSchema,
404: genericErrorSchema,
410: shareExpiredResponseSchema,
},
},
config: {
rateLimit: {
max: 60,
@@ -198,6 +267,14 @@ export async function shareRoutes(app: FastifyInstance) {
app.get<{ Params: { token: string } }>(
"/share/:token/overview",
{
schema: {
params: tokenParamsSchema,
response: {
200: shareOverviewResponseSchema,
404: genericErrorSchema,
410: shareOverviewExpiredResponseSchema,
},
},
config: {
rateLimit: {
max: 60,
@@ -268,7 +345,27 @@ export async function shareRoutes(app: FastifyInstance) {
// ---------------------------------------------------------------------------
app.post<{ Body: z.infer<typeof createShareSchema> }>(
"/share",
{ preHandler: requireAuth, schema: { tags: ["share"], security: protectedEndpointSecurity } },
{
preHandler: requireAuth,
schema: {
tags: ["share"],
security: protectedEndpointSecurity,
body: createShareBodyOpenApiSchema,
response: {
200: {
type: "object",
properties: {
reused: { type: "boolean" },
token: { type: "string" },
shareUrl: { type: "string" },
expiresAt: { type: ["string", "null"] },
},
},
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
@@ -351,7 +448,22 @@ export async function shareRoutes(app: FastifyInstance) {
// ---------------------------------------------------------------------------
app.get(
"/share/people",
{ preHandler: requireAuth, schema: { tags: ["share"], security: protectedEndpointSecurity } },
{
preHandler: requireAuth,
schema: {
tags: ["share"],
security: protectedEndpointSecurity,
response: {
200: {
type: "object",
properties: {
people: { type: "array", items: { type: "string" } },
},
},
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
+2 -1
View File
@@ -8,6 +8,7 @@ import sensible from "@fastify/sensible";
import type { Client } from "@libsql/client";
import Fastify, { type FastifyInstance } from "fastify";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
// Use vi.hoisted to create the db BEFORE mocks are set up
const { testClient, testDb } = vi.hoisted(() => {
@@ -97,7 +98,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
beforeAll(async () => {
await createSchema(testClient);
app = Fastify({ logger: false });
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(sensible);
await app.register(cookie, { secret: "test-cookie-secret-12345" });
+2 -1
View File
@@ -7,6 +7,7 @@ import { migrate } from "drizzle-orm/libsql/migrator";
import Fastify, { type FastifyInstance } from "fastify";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { runAlterMigrations } from "../db/db-utils.js";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
const { testClient, testDb, mockedEnv } = vi.hoisted(() => {
const { createClient } = require("@libsql/client");
@@ -226,7 +227,7 @@ describe("Real business route authz contracts", () => {
await migrate(testDb, { migrationsFolder });
await runAlterMigrations(testClient);
app = Fastify({ logger: false });
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(sensible);
await app.register(cookie, { secret: "test-cookie-secret" });
await app.register(jwt, {
+2 -1
View File
@@ -6,6 +6,7 @@ import { migrate } from "drizzle-orm/libsql/migrator";
import Fastify, { type FastifyInstance } from "fastify";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { runAlterMigrations } from "../db/db-utils.js";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
const { testClient, testDb, mockedEnv } = vi.hoisted(() => {
const { createClient } = require("@libsql/client");
@@ -104,7 +105,7 @@ describe("Dose Tracking API", () => {
await migrate(testDb, { migrationsFolder });
await runAlterMigrations(testClient);
app = Fastify({ logger: false });
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(cookie, { secret: "test-cookie-secret" });
await app.register(jwt, {
secret: "test-jwt-secret",
+2 -1
View File
@@ -10,6 +10,7 @@ import sensible from "@fastify/sensible";
import type { Client } from "@libsql/client";
import Fastify, { type FastifyInstance } from "fastify";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
// Use vi.hoisted to create the db BEFORE mocks are set up
const { testClient, testDb } = vi.hoisted(() => {
@@ -247,7 +248,7 @@ describe("E2E Tests with Real Routes", () => {
await createSchema(testClient);
// Build app with real routes
app = Fastify({ logger: false });
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(sensible);
await app.register(cookie, { secret: "test-cookie-secret" });
+2 -1
View File
@@ -10,6 +10,7 @@ import sensible from "@fastify/sensible";
import type { Client } from "@libsql/client";
import Fastify, { type FastifyInstance } from "fastify";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
// Use vi.hoisted to create the db BEFORE mocks are set up
const { testClient, testDb } = vi.hoisted(() => {
@@ -203,7 +204,7 @@ describe("Integration Tests", () => {
beforeAll(async () => {
await createSchema(testClient);
app = Fastify({ logger: false });
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(sensible);
await app.register(cookie, { secret: "test-cookie-secret" });
await app.register(jwt, {
+2 -1
View File
@@ -1,6 +1,7 @@
import cookie from "@fastify/cookie";
import Fastify from "fastify";
import { afterEach, describe, expect, it, vi } from "vitest";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
type OidcMocks = {
discovery: ReturnType<typeof vi.fn>;
@@ -54,7 +55,7 @@ async function buildOidcApp(envOverrides: Record<string, unknown>) {
const { oidcRoutes } = await import("../routes/oidc.js");
const app = Fastify({ logger: false });
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(cookie, { secret: "test-cookie-secret" });
app.decorate("config", {
accessSecret: "test-jwt-secret-12345",
+2 -1
View File
@@ -1,6 +1,7 @@
import type { Client } from "@libsql/client";
import Fastify, { type FastifyInstance } from "fastify";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
// Create test database and mocks before anything else (hoisted)
const {
@@ -214,7 +215,7 @@ describe("Planner Routes", () => {
args: [],
});
app = Fastify({ logger: false });
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(plannerRoutes);
await app.ready();
+2 -1
View File
@@ -4,6 +4,7 @@ import { migrate } from "drizzle-orm/libsql/migrator";
import Fastify, { type FastifyInstance } from "fastify";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { runAlterMigrations } from "../db/db-utils.js";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
const { testClient, testDb, mockedEnv, nodemailerSendMail, fetchMock } = vi.hoisted(() => {
const { createClient } = require("@libsql/client");
@@ -108,7 +109,7 @@ describe("Real route coverage: settings/export/report", () => {
beforeAll(async () => {
await migrate(testDb, { migrationsFolder });
await runAlterMigrations(testClient);
app = Fastify({ logger: false });
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(settingsRoutes);
await app.register(exportRoutes);
await app.register(reportRoutes);
+9 -7
View File
@@ -6,6 +6,7 @@ import cors from "@fastify/cors";
import sensible from "@fastify/sensible";
import Fastify, { type FastifyInstance } from "fastify";
import { afterEach, describe, expect, it } from "vitest";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
// Import from utils to avoid index.ts import side effects (server start)
import {
@@ -197,6 +198,7 @@ describe("Server Bootstrap", () => {
logger: {
level: "silent", // Disable logging for tests
},
ajv: documentationSchemaAjv,
});
expect(app).toBeDefined();
@@ -206,7 +208,7 @@ describe("Server Bootstrap", () => {
});
it("should register sensible plugin", async () => {
const app = Fastify({ logger: false });
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(sensible);
// Sensible adds error helpers
@@ -219,7 +221,7 @@ describe("Server Bootstrap", () => {
it("should register cors plugin with multiple origins", async () => {
const origins = ["http://localhost:5173", "http://localhost:4173"];
const app = Fastify({ logger: false });
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(cors, { origin: origins, credentials: true });
// Add a test route
@@ -243,7 +245,7 @@ describe("Server Bootstrap", () => {
});
it("should register cookie plugin", async () => {
const app = Fastify({ logger: false });
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(cookie, { secret: "test-cookie-secret" });
// Add a test route that sets a cookie
@@ -267,7 +269,7 @@ describe("Server Bootstrap", () => {
describe("Config Decorator", () => {
it("should create config with auth settings", async () => {
const app = Fastify({ logger: false });
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
const accessTtlMinutes = 15;
const refreshTtlDays = 7;
@@ -369,7 +371,7 @@ describe("Server Bootstrap", () => {
describe("Route Registration", () => {
it("should register multiple route plugins", async () => {
const app = Fastify({ logger: false });
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
// Mock route plugins
const healthRoutes = async (app: FastifyInstance) => {
@@ -402,7 +404,7 @@ describe("Server Bootstrap", () => {
describe("Server Startup", () => {
it("should listen on specified port", async () => {
const app = Fastify({ logger: false });
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
app.get("/test", async () => ({ ok: true }));
@@ -415,7 +417,7 @@ describe("Server Bootstrap", () => {
});
it("should handle listen errors gracefully", async () => {
const app = Fastify({ logger: false });
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
// Try to listen on an invalid port
await expect(app.listen({ port: -1, host: "127.0.0.1" })).rejects.toThrow();
@@ -7,6 +7,7 @@ import { migrate } from "drizzle-orm/libsql/migrator";
import Fastify, { type FastifyInstance } from "fastify";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { runAlterMigrations } from "../db/db-utils.js";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
const { testClient, testDb, mockedEnv, nodemailerSendMail } = vi.hoisted(() => {
const { createClient } = require("@libsql/client");
@@ -115,7 +116,7 @@ describe("Settings and API key security contracts", () => {
await migrate(testDb, { migrationsFolder });
await runAlterMigrations(testClient);
app = Fastify({ logger: false });
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(sensible);
await app.register(cookie, { secret: "test-cookie-secret" });
await app.register(jwt, {
+2 -1
View File
@@ -13,6 +13,7 @@ import { type Client, createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import { migrate } from "drizzle-orm/libsql/migrator";
import Fastify, { type FastifyInstance } from "fastify";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
// Get migrations folder path
const __filename = fileURLToPath(import.meta.url);
@@ -44,7 +45,7 @@ export async function buildTestApp(): Promise<TestContext> {
await runTestMigrations(client);
// Create Fastify app with minimal plugins
const app = Fastify({ logger: false });
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(sensible);
await app.register(cookie, { secret: "test-cookie-secret" });
@@ -4,6 +4,7 @@ import { migrate } from "drizzle-orm/libsql/migrator";
import Fastify, { type FastifyInstance } from "fastify";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { runAlterMigrations } from "../db/db-utils.js";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
const { testClient, testDb, mockedEnv } = vi.hoisted(() => {
const { createClient } = require("@libsql/client");
@@ -173,7 +174,7 @@ describe("Stock semantics parity (planner usage vs scheduler)", () => {
beforeAll(async () => {
await migrate(testDb, { migrationsFolder });
await runAlterMigrations(testClient);
app = Fastify({ logger: false });
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(medicationRoutes);
await app.ready();
});
@@ -0,0 +1,10 @@
import type { Plugin } from "ajv";
export const registerDocumentationSchemaKeywords: Plugin<unknown> = (ajv) => {
ajv.addKeyword({ keyword: "example", valid: true });
return ajv;
};
export const documentationSchemaAjv = {
plugins: [registerDocumentationSchemaKeywords],
};
@@ -4,6 +4,50 @@ type SecurityEntry = Readonly<Record<string, readonly string[]>>;
const defaultProtectedSecurity: readonly SecurityEntry[] = [{ bearerAuth: [] }, { cookieAuth: [] }];
export const genericErrorSchema = {
type: "object",
properties: {
error: { type: "string" },
code: { type: "string" },
},
} as const;
export const validationErrorSchema = {
type: "object",
additionalProperties: true,
} as const;
export const idParamsSchema = {
type: "object",
required: ["id"],
properties: {
id: { type: "string", pattern: "^\\d+$" },
},
} as const;
export const tokenParamsSchema = {
type: "object",
required: ["token"],
properties: {
token: { type: "string", minLength: 1 },
},
} as const;
export const successResponseSchema = {
type: "object",
properties: {
success: { type: "boolean" },
},
} as const;
export const messageResponseSchema = {
type: "object",
properties: {
success: { type: "boolean" },
message: { type: "string" },
},
} as const;
export type OpenApiRouteStandardsOptions = {
tag: string;
protectedByDefault: boolean;