feat: backend API key auth context and settings hardening (#406)

* feat: add backend api-key auth context and settings hardening

* fix: harden api key token hashing
This commit is contained in:
Daniel Volz
2026-03-10 06:26:20 +01:00
committed by GitHub
parent 105eb7bc0d
commit c0507c4c4b
29 changed files with 4801 additions and 875 deletions
@@ -0,0 +1,18 @@
CREATE TABLE `api_keys` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`name` text(100) NOT NULL,
`key_hash` text(128) NOT NULL,
`token_prefix` text(24) DEFAULT '' NOT NULL,
`scope` text(10) DEFAULT 'write' NOT NULL,
`is_active` integer DEFAULT true NOT NULL,
`last_used_at` integer,
`expires_at` integer,
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
`updated_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `api_keys_key_hash_unique` ON `api_keys` (`key_hash`);--> statement-breakpoint
ALTER TABLE `medications` ADD `package_amount_value` integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE `medications` ADD `package_amount_unit` text(10) DEFAULT 'ml' NOT NULL;
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -76,14 +76,21 @@
"idx": 10,
"version": "6",
"when": 1771694832866,
"tag": "0010_mean_spot",
"tag": "0010_add_dose_tracking_taken_source",
"breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1772219947541,
"tag": "0011_stiff_randall_flagg",
"tag": "0011_add_medication_form_lifecycle_columns",
"breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1772881208026,
"tag": "0012_add_api_keys_and_package_amount_columns",
"breakpoints": true
}
]
+86 -2
View File
@@ -16,6 +16,8 @@
"@fastify/rate-limit": "^10.3.0",
"@fastify/sensible": "^6.0.4",
"@fastify/static": "^9.0.0",
"@fastify/swagger": "^9.7.0",
"@fastify/swagger-ui": "^5.2.5",
"@libsql/client": "^0.17.0",
"argon2": "^0.44.0",
"dotenv": "^17.3.1",
@@ -1512,6 +1514,52 @@
"glob": "^13.0.0"
}
},
"node_modules/@fastify/swagger": {
"version": "9.7.0",
"resolved": "https://registry.npmjs.org/@fastify/swagger/-/swagger-9.7.0.tgz",
"integrity": "sha512-Vp1SC1GC2Hrkd3faFILv86BzUNyFz5N4/xdExqtCgkGASOzn/x+eMe4qXIGq7cdT6wif/P/oa6r1Ruqx19paZA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"fastify-plugin": "^5.0.0",
"json-schema-resolver": "^3.0.0",
"openapi-types": "^12.1.3",
"rfdc": "^1.3.1",
"yaml": "^2.4.2"
}
},
"node_modules/@fastify/swagger-ui": {
"version": "5.2.5",
"resolved": "https://registry.npmjs.org/@fastify/swagger-ui/-/swagger-ui-5.2.5.tgz",
"integrity": "sha512-ky3I0LAkXKX/prwSDpoQ3kscBKsj2Ha6Gp1/JfgQSqyx0bm9F2bE//XmGVGj2cR9l5hUjZYn60/hqn7e+OLgWQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/static": "^9.0.0",
"fastify-plugin": "^5.0.0",
"openapi-types": "^12.1.3",
"rfdc": "^1.3.1",
"yaml": "^2.4.1"
}
},
"node_modules/@img/colour": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
@@ -3183,7 +3231,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -4695,6 +4742,23 @@
"dequal": "^2.0.3"
}
},
"node_modules/json-schema-resolver": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/json-schema-resolver/-/json-schema-resolver-3.0.0.tgz",
"integrity": "sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==",
"license": "MIT",
"dependencies": {
"debug": "^4.1.1",
"fast-uri": "^3.0.5",
"rfdc": "^1.1.4"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/Eomm/json-schema-resolver?sponsor=1"
}
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -4936,7 +5000,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
@@ -5083,6 +5146,12 @@
"wrappy": "1"
}
},
"node_modules/openapi-types": {
"version": "12.1.3",
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
"integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==",
"license": "MIT"
},
"node_modules/openid-client": {
"version": "6.8.2",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.2.tgz",
@@ -6207,6 +6276,21 @@
"node": ">=0.4"
}
},
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+2
View File
@@ -25,6 +25,8 @@
"@fastify/rate-limit": "^10.3.0",
"@fastify/sensible": "^6.0.4",
"@fastify/static": "^9.0.0",
"@fastify/swagger": "^9.7.0",
"@fastify/swagger-ui": "^5.2.5",
"@libsql/client": "^0.17.0",
"argon2": "^0.44.0",
"dotenv": "^17.3.1",
+18 -1
View File
@@ -189,7 +189,21 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
packs_added INTEGER NOT NULL DEFAULT 0,
loose_pills_added INTEGER NOT NULL DEFAULT 0,
refill_date INTEGER NOT NULL DEFAULT (strftime('%s','now'))
)`,
)`,
// Added in v1.20.x - API key authentication for programmatic access
`CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
token_prefix TEXT NOT NULL DEFAULT '',
scope TEXT NOT NULL DEFAULT 'write',
is_active INTEGER NOT NULL DEFAULT 1,
last_used_at INTEGER,
expires_at INTEGER,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
)`,
];
for (const sql of createTableMigrations) {
@@ -207,6 +221,9 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
const createIndexMigrations = [
// Added in v1.6.x - case-insensitive unique usernames
`CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_unique ON users(lower(username))`,
// Added in v1.20.x - fast API key lookup and ownership filtering
`CREATE UNIQUE INDEX IF NOT EXISTS api_keys_key_hash_unique ON api_keys(key_hash)`,
`CREATE INDEX IF NOT EXISTS api_keys_user_id_idx ON api_keys(user_id)`,
];
for (const sql of createIndexMigrations) {
+19
View File
@@ -146,6 +146,25 @@ export const refreshTokens = sqliteTable("refresh_tokens", {
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
});
// =============================================================================
// API Keys - Personal access tokens for programmatic API access
// =============================================================================
export const apiKeys = sqliteTable("api_keys", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name", { length: 100 }).notNull(),
keyHash: text("key_hash", { length: 128 }).notNull().unique(),
tokenPrefix: text("token_prefix", { length: 24 }).notNull().default(""),
scope: text("scope", { length: 10 }).notNull().default("write"), // 'read' | 'write'
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
lastUsedAt: integer("last_used_at", { mode: "timestamp" }),
expiresAt: integer("expires_at", { mode: "timestamp" }),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
});
// =============================================================================
// Share Tokens - For public schedule sharing by takenBy person
// =============================================================================
+60 -1
View File
@@ -10,10 +10,13 @@ import fastifyMultipart from "@fastify/multipart";
import rateLimit from "@fastify/rate-limit";
import sensible from "@fastify/sensible";
import fastifyStatic from "@fastify/static";
import fastifySwagger from "@fastify/swagger";
import fastifySwaggerUi from "@fastify/swagger-ui";
import Fastify, { type FastifyInstance } from "fastify";
import { migrationsReady } from "./db/client.js";
import { getDataDir } from "./db/db-utils.js";
import { env } from "./plugins/env.js";
import { apiKeyRoutes } from "./routes/api-keys.js";
import { authRoutes } from "./routes/auth.js";
import { doseRoutes } from "./routes/doses.js";
import { exportRoutes } from "./routes/export.js";
@@ -58,12 +61,13 @@ function sanitizeCorrelationId(headers: IncomingHttpHeaders): string | null {
}
function buildLoggerOptions(level: string) {
const runtimeEnv = process.env.NODE_ENV ?? "production";
const base = {
level,
timestamp: () => `,"time":"${new Date().toISOString()}"`,
};
// Human-readable logs in development, structured JSON in production/test
if (process.env.NODE_ENV !== "production" && process.env.NODE_ENV !== "test") {
if (runtimeEnv === "development") {
return {
...base,
transport: { target: "pino-pretty", options: { translateTime: "SYS:yyyy-mm-dd HH:MM:ss.l" } },
@@ -72,6 +76,55 @@ function buildLoggerOptions(level: string) {
return base;
}
async function registerApiDocs(app: FastifyInstance, enabled: boolean) {
if (!enabled) return;
await app.register(fastifySwagger, {
openapi: {
openapi: "3.0.3",
info: {
title: "MedAssist-ng API",
description: "MedAssist-ng backend API",
version: process.env.npm_package_version ?? "dev",
},
servers: [{ url: "/", description: "Current server" }],
tags: [
{ name: "health", description: "Service health endpoints" },
{ name: "auth", description: "Authentication and profile endpoints" },
{ name: "api-keys", description: "Programmatic API key management" },
{ name: "settings", description: "User settings and notification test endpoints" },
],
components: {
securitySchemes: {
bearerAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "API key or JWT",
description: "Use Authorization: Bearer ma_... (API key) or a JWT token.",
},
cookieAuth: {
type: "apiKey",
in: "cookie",
name: "access_token",
description: "Session cookie set by login.",
},
},
},
},
hideUntagged: false,
});
await app.register(fastifySwaggerUi, {
routePrefix: "/docs",
staticCSP: true,
transformSpecificationClone: true,
uiConfig: {
docExpansion: "list",
deepLinking: false,
},
});
}
/** Create and configure Fastify app (without starting) */
export async function createApp(options?: {
logLevel?: string;
@@ -84,6 +137,7 @@ export async function createApp(options?: {
refreshTtlDays?: number;
isProduction?: boolean;
imagesDir?: string;
openApiDocsEnabled?: boolean;
}): Promise<FastifyInstance> {
const opts = {
logLevel: options?.logLevel ?? "info",
@@ -96,6 +150,7 @@ export async function createApp(options?: {
refreshTtlDays: options?.refreshTtlDays ?? 7,
isProduction: options?.isProduction ?? false,
imagesDir: options?.imagesDir ?? resolve(getDataDir(), "images"),
openApiDocsEnabled: options?.openApiDocsEnabled ?? false,
};
const app = Fastify({
@@ -132,6 +187,7 @@ export async function createApp(options?: {
await app.register(jwt, jwtConfig);
await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } });
await registerApiDocs(app, opts.openApiDocsEnabled);
// Only register static if directory exists
if (existsSync(opts.imagesDir)) {
@@ -145,6 +201,7 @@ export async function createApp(options?: {
// Register routes
await app.register(healthRoutes);
await app.register(authRoutes);
await app.register(apiKeyRoutes);
await app.register(oidcRoutes);
await app.register(medicationRoutes);
await app.register(settingsRoutes);
@@ -215,6 +272,7 @@ const jwtConfig = getJwtConfig(env.AUTH_ENABLED, env.JWT_SECRET);
await app.register(jwt, jwtConfig);
await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } }); // 10MB limit
await registerApiDocs(app, env.OPENAPI_DOCS_ENABLED);
await app.register(fastifyStatic, {
root: imagesDir,
prefix: "/images/",
@@ -223,6 +281,7 @@ await app.register(fastifyStatic, {
await app.register(healthRoutes);
await app.register(authRoutes);
await app.register(apiKeyRoutes);
await app.register(oidcRoutes);
await app.register(medicationRoutes);
await app.register(settingsRoutes);
+121 -3
View File
@@ -1,7 +1,8 @@
import { count, eq, sql } from "drizzle-orm";
import { pbkdf2Sync } from "node:crypto";
import { and, count, eq, sql } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { db } from "../db/client.js";
import { users } from "../db/schema.js";
import { apiKeys, users } from "../db/schema.js";
import { env } from "./env.js";
// =============================================================================
@@ -82,6 +83,84 @@ export interface RequestUser {
username: string;
}
const READ_ONLY_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
function isMutationMethod(method: string): boolean {
return !READ_ONLY_METHODS.has(method.toUpperCase());
}
function getApiKeyPepper(): string {
return env.JWT_SECRET || env.REFRESH_SECRET || "medassist-api-key-pepper";
}
export function hashApiKeyToken(token: string): string {
return pbkdf2Sync(token, getApiKeyPepper(), 120_000, 64, "sha512").toString("hex");
}
function getBearerToken(request: FastifyRequest): string | null {
const authHeader = request.headers.authorization;
if (!authHeader) return null;
const [scheme, value] = authHeader.split(" ");
if (!scheme || !value) return null;
if (scheme.toLowerCase() !== "bearer") return null;
const token = value.trim();
return token.length > 0 ? token : null;
}
async function tryApiKeyAuth(request: FastifyRequest, reply: FastifyReply): Promise<boolean> {
const bearerToken = getBearerToken(request);
if (!bearerToken) return false;
if (!bearerToken.startsWith("ma_")) {
reply.status(401).send({ error: "Invalid API key", code: "INVALID_API_KEY" });
throw new Error("INVALID_API_KEY");
}
const keyHash = hashApiKeyToken(bearerToken);
const [keyRow] = await db
.select()
.from(apiKeys)
.where(and(eq(apiKeys.keyHash, keyHash), eq(apiKeys.isActive, true)));
if (!keyRow) {
reply.status(401).send({ error: "Invalid API key", code: "INVALID_API_KEY" });
throw new Error("INVALID_API_KEY");
}
if (keyRow.expiresAt && keyRow.expiresAt.getTime() <= Date.now()) {
reply.status(401).send({ error: "API key expired", code: "API_KEY_EXPIRED" });
throw new Error("API_KEY_EXPIRED");
}
const [user] = await db.select().from(users).where(eq(users.id, keyRow.userId));
if (!user || !user.isActive) {
reply.status(401).send({ error: "User not found", code: "USER_NOT_FOUND" });
throw new Error("USER_NOT_FOUND");
}
const scope = keyRow.scope === "read" ? "read" : "write";
if (scope === "read" && isMutationMethod(request.method)) {
reply.status(403).send({ error: "API key scope does not allow this operation", code: "API_KEY_SCOPE_FORBIDDEN" });
throw new Error("API_KEY_SCOPE_FORBIDDEN");
}
request.user = { id: user.id, username: user.username };
request.authContext = {
method: "api_key",
scope,
apiKeyId: keyRow.id,
};
await db
.update(apiKeys)
.set({ lastUsedAt: new Date(), updatedAt: new Date() })
.where(and(eq(apiKeys.id, keyRow.id), eq(apiKeys.userId, user.id)));
return true;
}
// =============================================================================
// Auth Middleware Functions
// =============================================================================
@@ -94,6 +173,28 @@ export async function optionalAuth(request: FastifyRequest, _reply: FastifyReply
return;
}
const bearerToken = getBearerToken(request);
if (bearerToken?.startsWith("ma_")) {
const keyHash = hashApiKeyToken(bearerToken);
const [keyRow] = await db
.select()
.from(apiKeys)
.where(and(eq(apiKeys.keyHash, keyHash), eq(apiKeys.isActive, true)));
if (!keyRow) return;
if (keyRow.expiresAt && keyRow.expiresAt.getTime() <= Date.now()) return;
const [userByKey] = await db.select().from(users).where(eq(users.id, keyRow.userId));
if (userByKey?.isActive) {
request.user = { id: userByKey.id, username: userByKey.username };
request.authContext = {
method: "api_key",
scope: keyRow.scope === "read" ? "read" : "write",
apiKeyId: keyRow.id,
};
}
return;
}
const token = request.cookies.access_token;
if (!token) {
return;
@@ -107,6 +208,10 @@ export async function optionalAuth(request: FastifyRequest, _reply: FastifyReply
id: user.id,
username: user.username,
};
request.authContext = {
method: "session",
scope: "write",
};
}
} catch {
// Invalid token, continue as anonymous
@@ -121,6 +226,10 @@ export async function requireAuth(request: FastifyRequest, reply: FastifyReply)
return;
}
if (await tryApiKeyAuth(request, reply)) {
return;
}
const token = request.cookies.access_token;
if (!token) {
reply.status(401).send({ error: "Authentication required", code: "AUTH_REQUIRED" });
@@ -145,11 +254,20 @@ export async function requireAuth(request: FastifyRequest, reply: FastifyReply)
id: user.id,
username: user.username,
};
request.authContext = {
method: "session",
scope: "write",
};
} catch (err: unknown) {
// Re-throw our own errors
if (
err instanceof Error &&
(err.message === "AUTH_REQUIRED" || err.message === "USER_NOT_FOUND" || err.message === "ACCOUNT_DISABLED")
(err.message === "AUTH_REQUIRED" ||
err.message === "USER_NOT_FOUND" ||
err.message === "ACCOUNT_DISABLED" ||
err.message === "INVALID_API_KEY" ||
err.message === "API_KEY_EXPIRED" ||
err.message === "API_KEY_SCOPE_FORBIDDEN")
) {
throw err;
}
+14 -3
View File
@@ -14,6 +14,10 @@ const EnvSchema = z.object({
.default("3000"),
CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"),
LOG_LEVEL: z.string().default("info"),
OPENAPI_DOCS_ENABLED: z
.string()
.transform((v) => v === "true")
.optional(),
// ==========================================================================
// Auth Configuration
@@ -69,10 +73,13 @@ const EnvSchema = z.object({
OIDC_PROVIDER_NAME: z.string().default("SSO"), // Display name for UI button
});
export type Env = z.infer<typeof EnvSchema>;
type ParsedEnv = z.infer<typeof EnvSchema>;
export type Env = ParsedEnv & {
OPENAPI_DOCS_ENABLED: boolean;
};
// Parse and validate
let parsed: z.infer<typeof EnvSchema>;
let parsed: ParsedEnv;
try {
parsed = EnvSchema.parse(process.env);
} catch (err) {
@@ -154,4 +161,8 @@ if (parsed.REGISTRATION_ENABLED && !parsed.FORM_LOGIN_ENABLED) {
);
}
export const env = parsed;
export const env: Env = {
...parsed,
// Docs UI/spec are enabled in non-production by default.
OPENAPI_DOCS_ENABLED: parsed.OPENAPI_DOCS_ENABLED ?? parsed.NODE_ENV !== "production",
};
+294
View File
@@ -0,0 +1,294 @@
import { randomBytes } from "node:crypto";
import { and, desc, eq } from "drizzle-orm";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
import { apiKeys } from "../db/schema.js";
import { hashApiKeyToken, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js";
const createApiKeySchema = z.object({
name: z.string().trim().min(3).max(100),
scope: z.enum(["read", "write"]).default("write"),
expiresInDays: z.number().int().min(1).max(3650).optional(),
});
const idParamSchema = z.object({
id: z.string().regex(/^\d+$/),
});
const protectedEndpointSecurity = [{ bearerAuth: [] }];
const genericErrorSchema = {
type: "object",
properties: {
error: { type: "string" },
code: { type: "string" },
},
};
const apiKeyMetadataSchema = {
type: "object",
properties: {
id: { type: "number" },
name: { type: "string" },
tokenPrefix: { type: "string" },
scope: { type: "string", enum: ["read", "write"] },
isActive: { type: "boolean" },
lastUsedAt: { type: ["string", "null"], format: "date-time" },
expiresAt: { type: ["string", "null"], format: "date-time" },
createdAt: { type: ["string", "null"], format: "date-time" },
updatedAt: { type: ["string", "null"], format: "date-time" },
},
};
function normalizeDateTime(value: unknown): string | null {
if (value == null) {
return null;
}
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value.toISOString();
}
if (typeof value === "number") {
const timestampMs = value < 1_000_000_000_000 ? value * 1000 : value;
const date = new Date(timestampMs);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
if (typeof value === "string") {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
return null;
}
function serializeApiKeyMetadata<
T extends {
id: number;
name: string;
tokenPrefix: string;
scope: string;
isActive: boolean;
lastUsedAt: unknown;
expiresAt: unknown;
createdAt: unknown;
updatedAt: unknown;
},
>(key: T) {
return {
id: key.id,
name: key.name,
tokenPrefix: key.tokenPrefix,
scope: key.scope,
isActive: key.isActive,
lastUsedAt: normalizeDateTime(key.lastUsedAt),
expiresAt: normalizeDateTime(key.expiresAt),
createdAt: normalizeDateTime(key.createdAt),
updatedAt: normalizeDateTime(key.updatedAt),
};
}
export async function apiKeyRoutes(app: FastifyInstance) {
app.addHook("preHandler", requireAuth);
app.get(
"/auth/api-keys",
{
schema: {
tags: ["api-keys"],
summary: "List API keys for the current user",
description: "Returns API key metadata. Raw API key tokens are never returned.",
security: protectedEndpointSecurity,
response: {
200: {
type: "object",
properties: {
keys: {
type: "array",
items: apiKeyMetadataSchema,
},
},
},
400: genericErrorSchema,
401: genericErrorSchema,
},
},
},
async (request, reply) => {
if (!env.AUTH_ENABLED) {
return reply.status(400).send({ error: "API keys are unavailable when auth is disabled" });
}
const authUser = request.user as unknown as AuthUser | null;
if (!authUser) {
return reply.status(401).send({ error: "User not authenticated", code: "AUTH_REQUIRED" });
}
const keys = await db
.select({
id: apiKeys.id,
name: apiKeys.name,
tokenPrefix: apiKeys.tokenPrefix,
scope: apiKeys.scope,
isActive: apiKeys.isActive,
lastUsedAt: apiKeys.lastUsedAt,
expiresAt: apiKeys.expiresAt,
createdAt: apiKeys.createdAt,
updatedAt: apiKeys.updatedAt,
})
.from(apiKeys)
.where(eq(apiKeys.userId, authUser.id))
.orderBy(desc(apiKeys.createdAt));
return { keys: keys.map(serializeApiKeyMetadata) };
}
);
app.post<{ Body: z.infer<typeof createApiKeySchema> }>(
"/auth/api-keys",
{
schema: {
tags: ["api-keys"],
summary: "Create and rotate API key",
description:
"Creates a new API key and deactivates previously active API keys for the current user. The new token is returned only once.",
security: protectedEndpointSecurity,
body: {
type: "object",
required: ["name"],
properties: {
name: { type: "string", minLength: 3, maxLength: 100 },
scope: { type: "string", enum: ["read", "write"], default: "write" },
expiresInDays: { type: "number", minimum: 1, maximum: 3650 },
},
},
response: {
201: {
type: "object",
properties: {
key: apiKeyMetadataSchema,
token: { type: "string" },
note: { type: "string" },
},
},
400: { anyOf: [genericErrorSchema, { type: "object" }] },
401: genericErrorSchema,
},
},
},
async (request, reply) => {
if (!env.AUTH_ENABLED) {
return reply.status(400).send({ error: "API keys are unavailable when auth is disabled" });
}
const parsed = createApiKeySchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send(parsed.error.format());
}
const authUser = request.user as unknown as AuthUser | null;
if (!authUser) {
return reply.status(401).send({ error: "User not authenticated", code: "AUTH_REQUIRED" });
}
const { name, scope, expiresInDays } = parsed.data;
const rawToken = `ma_${randomBytes(32).toString("hex")}`;
const tokenPrefix = `${rawToken.slice(0, 12)}...`;
const keyHash = hashApiKeyToken(rawToken);
const expiresAt = expiresInDays ? new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000) : null;
// Keep a single active key per user: creating a new key invalidates old ones.
await db
.update(apiKeys)
.set({ isActive: false, updatedAt: new Date() })
.where(and(eq(apiKeys.userId, authUser.id), eq(apiKeys.isActive, true)));
const [created] = await db
.insert(apiKeys)
.values({
userId: authUser.id,
name,
keyHash,
tokenPrefix,
scope,
expiresAt,
})
.returning({
id: apiKeys.id,
name: apiKeys.name,
tokenPrefix: apiKeys.tokenPrefix,
scope: apiKeys.scope,
isActive: apiKeys.isActive,
lastUsedAt: apiKeys.lastUsedAt,
expiresAt: apiKeys.expiresAt,
createdAt: apiKeys.createdAt,
updatedAt: apiKeys.updatedAt,
});
return reply.status(201).send({
key: serializeApiKeyMetadata(created),
token: rawToken,
note: "Store this token now. It cannot be retrieved again.",
});
}
);
app.delete<{ Params: { id: string } }>(
"/auth/api-keys/:id",
{
schema: {
tags: ["api-keys"],
summary: "Deactivate API key",
description: "Deactivates one API key belonging to the current user.",
security: protectedEndpointSecurity,
params: {
type: "object",
required: ["id"],
properties: {
id: { type: "string", pattern: "^\\d+$" },
},
},
response: {
204: { type: "null" },
400: { anyOf: [genericErrorSchema, { type: "object" }] },
401: genericErrorSchema,
404: genericErrorSchema,
},
},
},
async (request, reply) => {
if (!env.AUTH_ENABLED) {
return reply.status(400).send({ error: "API keys are unavailable when auth is disabled" });
}
const parsedParams = idParamSchema.safeParse(request.params);
if (!parsedParams.success) {
return reply.status(400).send(parsedParams.error.format());
}
const authUser = request.user as unknown as AuthUser | null;
if (!authUser) {
return reply.status(401).send({ error: "User not authenticated", code: "AUTH_REQUIRED" });
}
const keyId = Number(parsedParams.data.id);
const [existing] = await db
.select({ id: apiKeys.id, userId: apiKeys.userId })
.from(apiKeys)
.where(and(eq(apiKeys.id, keyId), eq(apiKeys.userId, authUser.id)));
if (!existing) {
return reply.status(404).send({ error: "API key not found", code: "API_KEY_NOT_FOUND" });
}
await db
.update(apiKeys)
.set({ isActive: false, updatedAt: new Date() })
.where(and(eq(apiKeys.id, keyId), eq(apiKeys.userId, authUser.id)));
return reply.status(204).send();
}
);
}
+252 -21
View File
@@ -85,6 +85,38 @@ const updateProfileSchema = z.object({
.optional(),
});
const authEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>> = [{ bearerAuth: [] }, { cookieAuth: [] }];
const authErrorSchema = {
type: "object",
properties: {
error: { type: "string" },
code: { type: "string" },
},
};
function normalizeDateTime(value: unknown): string | null {
if (value == null) {
return null;
}
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value.toISOString();
}
if (typeof value === "number") {
const timestampMs = value < 1_000_000_000_000 ? value * 1000 : value;
const date = new Date(timestampMs);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
if (typeof value === "string") {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
return null;
}
// =============================================================================
// Auth Routes
// =============================================================================
@@ -99,9 +131,33 @@ export async function authRoutes(app: FastifyInstance) {
// GET /auth/state - Public auth state (needed before login)
// Exempt from rate limit - lightweight state check called frequently
// ---------------------------------------------------------------------------
app.get("/auth/state", { config: { rateLimit: false } }, async () => {
return getAuthState();
});
app.get(
"/auth/state",
{
config: { rateLimit: false },
schema: {
tags: ["auth"],
summary: "Get authentication state",
description: "Returns auth and login mode state before user login.",
response: {
200: {
type: "object",
properties: {
authEnabled: { type: "boolean" },
registrationEnabled: { type: "boolean" },
formLoginEnabled: { type: "boolean" },
oidcEnabled: { type: "boolean" },
hasUsers: { type: "boolean" },
oidcProviderName: { type: "string" },
},
},
},
},
},
async () => {
return getAuthState();
}
);
// ---------------------------------------------------------------------------
// POST /auth/register - User registration
@@ -110,6 +166,36 @@ export async function authRoutes(app: FastifyInstance) {
"/auth/register",
{
config: { rateLimit: sensitiveRateLimitConfig },
schema: {
tags: ["auth"],
summary: "Register local user",
body: {
type: "object",
required: ["username", "password"],
properties: {
username: { type: "string", minLength: 3, maxLength: 50 },
password: { type: "string", minLength: 8, maxLength: 128 },
},
},
response: {
201: {
type: "object",
properties: {
ok: { type: "boolean" },
user: {
type: "object",
properties: {
id: { type: "number" },
username: { type: "string" },
},
},
message: { type: "string" },
},
},
400: authErrorSchema,
409: authErrorSchema,
},
},
},
async (request, reply) => {
// Check auth state
@@ -177,6 +263,37 @@ export async function authRoutes(app: FastifyInstance) {
"/auth/login",
{
config: { rateLimit: sensitiveRateLimitConfig },
schema: {
tags: ["auth"],
summary: "Login with username and password",
body: {
type: "object",
required: ["username", "password"],
properties: {
username: { type: "string" },
password: { type: "string" },
rememberMe: { type: "boolean" },
},
},
response: {
200: {
type: "object",
properties: {
ok: { type: "boolean" },
user: {
type: "object",
properties: {
id: { type: "number" },
username: { type: "string" },
avatarUrl: { type: ["string", "null"] },
},
},
},
},
400: authErrorSchema,
401: authErrorSchema,
},
},
},
async (request, reply) => {
const state = await getAuthState();
@@ -281,6 +398,15 @@ export async function authRoutes(app: FastifyInstance) {
"/auth/refresh",
{
config: { rateLimit: authRateLimitConfig },
schema: {
tags: ["auth"],
summary: "Refresh access token",
description: "Requires refresh token cookie context.",
response: {
200: { type: "object", properties: { ok: { type: "boolean" } } },
401: authErrorSchema,
},
},
},
async (request, reply) => {
const refreshTokenCookie = request.cookies.refresh_token;
@@ -350,6 +476,13 @@ export async function authRoutes(app: FastifyInstance) {
"/auth/logout",
{
config: { rateLimit: authRateLimitConfig },
schema: {
tags: ["auth"],
summary: "Logout and clear auth cookies",
response: {
200: { type: "object", properties: { ok: { type: "boolean" } } },
},
},
},
async (request, reply) => {
const refreshTokenCookie = request.cookies.refresh_token;
@@ -375,26 +508,56 @@ export async function authRoutes(app: FastifyInstance) {
// ---------------------------------------------------------------------------
// GET /auth/me - Get current user profile
// ---------------------------------------------------------------------------
app.get("/auth/me", { preHandler: requireAuth }, async (request, reply) => {
const authUser = request.user as unknown as AuthUser | null;
if (!authUser) {
return reply.status(401).send({ error: "Not authenticated" });
}
app.get(
"/auth/me",
{
preHandler: requireAuth,
schema: {
tags: ["auth"],
summary: "Get current user profile",
security: authEndpointSecurity,
response: {
200: {
type: "object",
properties: {
id: { type: "number" },
username: { type: "string" },
avatarUrl: { type: ["string", "null"] },
authProvider: { type: "string" },
createdAt: { type: "string", format: "date-time" },
lastLoginAt: { type: ["string", "null"], format: "date-time" },
},
},
401: authErrorSchema,
404: authErrorSchema,
},
},
},
async (request, reply) => {
const authUser = request.user as unknown as AuthUser | null;
if (!authUser) {
return reply.status(401).send({ error: "Not authenticated" });
}
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
if (!user) {
return reply.status(404).send({ error: "User not found" });
}
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
if (!user) {
return reply.status(404).send({ error: "User not found" });
}
return {
id: user.id,
username: user.username,
avatarUrl: user.avatarUrl,
authProvider: user.authProvider,
createdAt: user.createdAt,
lastLoginAt: user.lastLoginAt,
};
});
const createdAt =
normalizeDateTime(user.createdAt) ?? normalizeDateTime(user.updatedAt) ?? new Date(0).toISOString();
const lastLoginAt = normalizeDateTime(user.lastLoginAt);
return {
id: user.id,
username: user.username,
avatarUrl: user.avatarUrl,
authProvider: user.authProvider ?? "local",
createdAt,
lastLoginAt,
};
}
);
// ---------------------------------------------------------------------------
// PUT /auth/me - Update current user profile
@@ -404,6 +567,30 @@ export async function authRoutes(app: FastifyInstance) {
{
preHandler: requireAuth,
config: { rateLimit: authRateLimitConfig },
schema: {
tags: ["auth"],
summary: "Update current user profile",
security: authEndpointSecurity,
body: {
type: "object",
properties: {
currentPassword: { type: "string" },
newPassword: { type: "string", minLength: 8, maxLength: 128 },
},
},
response: {
200: {
type: "object",
properties: {
ok: { type: "boolean" },
message: { type: "string" },
},
},
400: authErrorSchema,
401: authErrorSchema,
404: authErrorSchema,
},
},
},
async (request, reply) => {
const authUser = request.user as unknown as AuthUser | null;
@@ -462,6 +649,24 @@ export async function authRoutes(app: FastifyInstance) {
{
preHandler: requireAuth,
config: { rateLimit: authRateLimitConfig },
schema: {
tags: ["auth"],
summary: "Upload user avatar",
description: "Uploads and optimizes a profile image using multipart/form-data.",
security: authEndpointSecurity,
consumes: ["multipart/form-data"],
response: {
200: {
type: "object",
properties: {
ok: { type: "boolean" },
avatarUrl: { type: "string" },
},
},
400: authErrorSchema,
401: authErrorSchema,
},
},
},
async (request, reply) => {
const authUser = request.user as unknown as AuthUser | null;
@@ -517,6 +722,16 @@ export async function authRoutes(app: FastifyInstance) {
{
preHandler: requireAuth,
config: { rateLimit: authRateLimitConfig },
schema: {
tags: ["auth"],
summary: "Delete user avatar",
security: authEndpointSecurity,
response: {
200: { type: "object", properties: { ok: { type: "boolean" } } },
401: authErrorSchema,
404: authErrorSchema,
},
},
},
async (request, reply) => {
const authUser = request.user as unknown as AuthUser | null;
@@ -547,6 +762,22 @@ export async function authRoutes(app: FastifyInstance) {
{
preHandler: requireAuth,
config: { rateLimit: sensitiveRateLimitConfig },
schema: {
tags: ["auth"],
summary: "Delete current user account",
description: "Deletes the current account and related data (cascade delete).",
security: authEndpointSecurity,
response: {
200: {
type: "object",
properties: {
ok: { type: "boolean" },
message: { type: "string" },
},
},
401: authErrorSchema,
},
},
},
async (request, reply) => {
const authUser = request.user as unknown as AuthUser | null;
+37 -21
View File
@@ -267,6 +267,7 @@ export async function doseRoutes(app: FastifyInstance) {
userId,
doseId,
markedBy: null,
takenAt: new Date(0),
dismissed: true,
});
dismissedCount++;
@@ -291,7 +292,9 @@ export async function doseRoutes(app: FastifyInstance) {
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.dismissed, true)));
for (const d of dismissed) {
if (d.markedBy !== null || d.takenAt) {
const hasRealTakenTimestamp = d.takenAt instanceof Date ? d.takenAt.getTime() > 0 : Boolean(d.takenAt);
if (d.markedBy !== null || hasRealTakenTimestamp) {
// This was also marked as taken - just remove dismissed flag
await db.update(doseTracking).set({ dismissed: false }).where(eq(doseTracking.id, d.id));
} else {
@@ -307,28 +310,41 @@ export async function doseRoutes(app: FastifyInstance) {
// GET /share/:token/doses - PUBLIC: Get taken doses for a share link
// Suppress request logs — polled every 5s by SharedSchedule
// ---------------------------------------------------------------------------
app.get<{ Params: { token: string } }>("/share/:token/doses", { logLevel: "warn" }, async (request, reply) => {
const { token } = request.params;
app.get<{ Params: { token: string } }>(
"/share/:token/doses",
{
logLevel: "warn",
config: {
rateLimit: {
max: 60,
timeWindow: "1 minute",
errorResponseBuilder: () => ({ error: "rate_limited" }),
},
},
},
async (request, reply) => {
const { token } = request.params;
const { share, reason } = await getActiveShareToken(token);
if (!share) {
request.log.warn(`[ShareDose] Rejected read 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 read for token ${maskToken(token)} (reason=${reason})`);
return reply.notFound("Share link not found");
}
// Get all taken doses for this user (no time limit)
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId));
return {
doses: doses.map((d) => ({
doseId: d.doseId,
takenAt: d.takenAt?.getTime() ?? Date.now(),
markedBy: d.markedBy,
takenSource: d.takenSource ?? "manual",
dismissed: d.dismissed ?? false,
})),
};
}
// Get all taken doses for this user (no time limit)
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId));
return {
doses: doses.map((d) => ({
doseId: d.doseId,
takenAt: d.takenAt?.getTime() ?? Date.now(),
markedBy: d.markedBy,
takenSource: d.takenSource ?? "manual",
dismissed: d.dismissed ?? false,
})),
};
});
);
// ---------------------------------------------------------------------------
// POST /share/:token/doses - PUBLIC: Mark a dose as taken via share link
+63 -16
View File
@@ -6,6 +6,7 @@ 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 { isAmountBasedPackageType, normalizePackageType } from "../utils/package-profiles.js";
const refillSchema = z
.object({
@@ -52,9 +53,34 @@ export async function refillRoutes(app: FastifyInstance) {
if (!med) return reply.notFound("Medication not found");
const { packsAdded, loosePillsAdded, usePrescription } = parsed.data;
const isBottle = (med.packageType ?? "blister") === "bottle";
const effectivePacksAdded = isBottle ? 0 : packsAdded;
const effectiveLoosePillsAdded = loosePillsAdded;
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) {
@@ -76,6 +102,8 @@ export async function refillRoutes(app: FastifyInstance) {
// 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) {
@@ -85,14 +113,28 @@ export async function refillRoutes(app: FastifyInstance) {
? 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({
packCount: newPackCount,
looseTablets: newLooseTablets,
prescriptionRemainingRefills: newRemainingRefills,
updatedAt: new Date(),
})
.set(updatePayload)
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
// Create refill history entry
@@ -109,12 +151,15 @@ export async function refillRoutes(app: FastifyInstance) {
// Calculate pills added for response (packageType-aware)
const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
const totalPillsAdded = isBottle
const totalPillsAdded = isAmountBased
? effectiveLoosePillsAdded
: effectivePacksAdded * pillsPerPack + effectiveLoosePillsAdded;
const newTotalPills = isBottle
? newLooseTablets + (med.stockAdjustment ?? 0)
: newPackCount * pillsPerPack + newLooseTablets + (med.stockAdjustment ?? 0);
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,
@@ -158,17 +203,19 @@ export async function refillRoutes(app: FastifyInstance) {
const refills = await db
.select()
.from(refillHistory)
.where(eq(refillHistory.medicationId, medId))
.where(and(eq(refillHistory.medicationId, medId), eq(refillHistory.userId, userId)))
.orderBy(desc(refillHistory.refillDate));
const isBottle = (med.packageType ?? "blister") === "bottle";
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: isBottle ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded,
totalPillsAdded: isAmountBased ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded,
usedPrescription: r.usedPrescription ?? false,
refillDate: r.refillDate,
}));
+6 -3
View File
@@ -1,4 +1,4 @@
import { eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
@@ -90,8 +90,11 @@ export async function reportRoutes(app: FastifyInstance) {
const sortedTaken = takenDoses.map((d) => d.takenAt.getTime()).sort((a, b) => a - b);
// Get refills for this medication
const refills = await db.select().from(refillHistory).where(eq(refillHistory.medicationId, medId));
// 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,
+403 -216
View File
@@ -85,6 +85,18 @@ type TestShoutrrrBody = {
url: string;
};
const settingsEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>> = [
{ bearerAuth: [] },
{ cookieAuth: [] },
];
const settingsErrorSchema = {
type: "object",
properties: {
error: { type: "string" },
code: { type: "string" },
},
};
function maskEmail(email: string): string {
const [localPart, domain] = email.split("@");
if (!domain) return "invalid-email";
@@ -122,6 +134,38 @@ function getDeliveryError(info: MailDeliveryInfo): string | null {
return "SMTP did not confirm accepted recipients.";
}
function classifyTestEmailFailure(error: unknown): { status: number; code: string; message: string } {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
const normalizedMessage = errorMessage.toLowerCase();
if (
normalizedMessage.includes("smtp rejected all recipients") ||
normalizedMessage.includes("all recipients were rejected") ||
normalizedMessage.includes("recipient address rejected") ||
normalizedMessage.includes("nullmx")
) {
return {
status: 400,
code: "EMAIL_RECIPIENT_REJECTED",
message: `Failed to send email: ${errorMessage}`,
};
}
if (errorMessage.includes("SMTP did not confirm accepted recipients")) {
return {
status: 502,
code: "SMTP_DELIVERY_UNCONFIRMED",
message: `Failed to send email: ${errorMessage}`,
};
}
return {
status: 500,
code: "TEST_EMAIL_FAILED",
message: `Failed to send email: ${errorMessage}`,
};
}
function getNotificationProvider(url: string): string {
if (url.startsWith("discord://")) return "discord";
if (url.startsWith("telegram://")) return "telegram";
@@ -322,201 +366,313 @@ export async function settingsRoutes(app: FastifyInstance) {
// Get settings for current user
// Suppress request logs — polled every 30s for reminder status refresh
app.get("/settings", { logLevel: "warn" }, async (request, reply) => {
const userId = await getUserId(request, reply);
app.get(
"/settings",
{
logLevel: "warn",
schema: {
tags: ["settings"],
summary: "Get current user settings",
security: settingsEndpointSecurity,
response: {
200: { type: "object", additionalProperties: true },
401: settingsErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
const settings = await getOrCreateUserSettings(userId);
const reminderHour = envInt("REMINDER_HOUR", 6);
const reminderMinutesBefore = envInt("REMINDER_MINUTES_BEFORE", 15);
const settings = await getOrCreateUserSettings(userId);
const reminderHour = envInt("REMINDER_HOUR", 6);
const reminderMinutesBefore = envInt("REMINDER_MINUTES_BEFORE", 15);
return reply.send({
// User notification settings (from DB)
emailEnabled: settings.emailEnabled,
notificationEmail: settings.notificationEmail ?? "",
reminderDaysBefore: settings.reminderDaysBefore,
repeatDailyReminders: settings.repeatDailyReminders,
lowStockDays: settings.lowStockDays,
normalStockDays: settings.normalStockDays,
highStockDays: settings.highStockDays,
shoutrrrEnabled: settings.shoutrrrEnabled,
shoutrrrUrl: settings.shoutrrrUrl ?? "",
emailStockReminders: settings.emailStockReminders,
emailIntakeReminders: settings.emailIntakeReminders,
emailPrescriptionReminders: settings.emailPrescriptionReminders ?? true,
shoutrrrStockReminders: settings.shoutrrrStockReminders,
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
shoutrrrPrescriptionReminders: settings.shoutrrrPrescriptionReminders ?? true,
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses,
repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false,
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30,
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
language: settings.language,
stockCalculationMode: settings.stockCalculationMode ?? "automatic",
shareStockStatus: settings.shareStockStatus ?? true,
upcomingTodayOnly: settings.upcomingTodayOnly ?? false,
shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false,
swapDashboardMainSections: settings.swapDashboardMainSections ?? false,
// SMTP settings (from .env - shared/server-configured)
smtpHost: process.env.SMTP_HOST ?? "",
smtpPort: parseInt(process.env.SMTP_PORT ?? "587", 10),
smtpUser: process.env.SMTP_USER ?? "",
smtpFrom: process.env.SMTP_FROM ?? "",
smtpSecure: process.env.SMTP_SECURE === "true",
hasSmtpPassword: !!(process.env.SMTP_TOKEN || process.env.SMTP_PASS),
// Reminder state for this user
lastAutoEmailSent: settings.lastAutoEmailSent,
lastNotificationType: settings.lastNotificationType,
lastNotificationChannel: settings.lastNotificationChannel,
lastReminderMedName: settings.lastReminderMedName ?? null,
lastReminderTakenBy: settings.lastReminderTakenBy ?? null,
// Stock reminder tracking (separate from intake)
lastStockReminderSent: settings.lastStockReminderSent ?? null,
lastStockReminderChannel: settings.lastStockReminderChannel ?? null,
lastStockReminderMedNames: settings.lastStockReminderMedNames ?? null,
// Prescription reminder tracking (separate from stock/intake)
lastPrescriptionReminderSent: settings.lastPrescriptionReminderSent ?? null,
lastPrescriptionReminderChannel: settings.lastPrescriptionReminderChannel ?? null,
lastPrescriptionReminderMedNames: settings.lastPrescriptionReminderMedNames ?? null,
// Server settings (from .env, read-only)
reminderHour,
reminderMinutesBefore,
expiryWarningDays: parseInt(process.env.EXPIRY_WARNING_DAYS ?? "30", 10),
});
});
return reply.send({
// User notification settings (from DB)
emailEnabled: settings.emailEnabled,
notificationEmail: settings.notificationEmail ?? "",
reminderDaysBefore: settings.reminderDaysBefore,
repeatDailyReminders: settings.repeatDailyReminders,
lowStockDays: settings.lowStockDays,
normalStockDays: settings.normalStockDays,
highStockDays: settings.highStockDays,
shoutrrrEnabled: settings.shoutrrrEnabled,
shoutrrrUrl: settings.shoutrrrUrl ?? "",
emailStockReminders: settings.emailStockReminders,
emailIntakeReminders: settings.emailIntakeReminders,
emailPrescriptionReminders: settings.emailPrescriptionReminders ?? true,
shoutrrrStockReminders: settings.shoutrrrStockReminders,
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
shoutrrrPrescriptionReminders: settings.shoutrrrPrescriptionReminders ?? true,
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses,
repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false,
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30,
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
language: settings.language,
stockCalculationMode: settings.stockCalculationMode ?? "automatic",
shareStockStatus: settings.shareStockStatus ?? true,
upcomingTodayOnly: settings.upcomingTodayOnly ?? false,
shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false,
swapDashboardMainSections: settings.swapDashboardMainSections ?? false,
// SMTP settings (from .env - shared/server-configured)
smtpHost: process.env.SMTP_HOST ?? "",
smtpPort: parseInt(process.env.SMTP_PORT ?? "587", 10),
smtpUser: process.env.SMTP_USER ?? "",
smtpFrom: process.env.SMTP_FROM ?? "",
smtpSecure: process.env.SMTP_SECURE === "true",
hasSmtpPassword: !!(process.env.SMTP_TOKEN || process.env.SMTP_PASS),
// Reminder state for this user
lastAutoEmailSent: settings.lastAutoEmailSent,
lastNotificationType: settings.lastNotificationType,
lastNotificationChannel: settings.lastNotificationChannel,
lastReminderMedName: settings.lastReminderMedName ?? null,
lastReminderTakenBy: settings.lastReminderTakenBy ?? null,
// Stock reminder tracking (separate from intake)
lastStockReminderSent: settings.lastStockReminderSent ?? null,
lastStockReminderChannel: settings.lastStockReminderChannel ?? null,
lastStockReminderMedNames: settings.lastStockReminderMedNames ?? null,
// Prescription reminder tracking (separate from stock/intake)
lastPrescriptionReminderSent: settings.lastPrescriptionReminderSent ?? null,
lastPrescriptionReminderChannel: settings.lastPrescriptionReminderChannel ?? null,
lastPrescriptionReminderMedNames: settings.lastPrescriptionReminderMedNames ?? null,
// Server settings (from .env, read-only)
reminderHour,
reminderMinutesBefore,
expiryWarningDays: parseInt(process.env.EXPIRY_WARNING_DAYS ?? "30", 10),
});
}
);
// Update settings for current user
app.put<{ Body: SettingsBody }>("/settings", async (request, reply) => {
const userId = await getUserId(request, reply);
app.put<{ Body: SettingsBody }>(
"/settings",
{
schema: {
tags: ["settings"],
summary: "Update current user settings",
security: settingsEndpointSecurity,
body: {
type: "object",
required: ["emailEnabled", "notificationEmail", "reminderDaysBefore", "language"],
properties: {
emailEnabled: { type: "boolean" },
notificationEmail: { type: "string" },
reminderDaysBefore: { type: "number" },
repeatDailyReminders: { type: "boolean" },
lowStockDays: { type: "number" },
normalStockDays: { type: "number" },
highStockDays: { type: "number" },
shoutrrrEnabled: { type: "boolean" },
shoutrrrUrl: { type: "string" },
emailStockReminders: { type: "boolean" },
emailIntakeReminders: { type: "boolean" },
emailPrescriptionReminders: { type: "boolean" },
shoutrrrStockReminders: { type: "boolean" },
shoutrrrIntakeReminders: { type: "boolean" },
shoutrrrPrescriptionReminders: { type: "boolean" },
skipRemindersForTakenDoses: { type: "boolean" },
repeatRemindersEnabled: { type: "boolean" },
reminderRepeatIntervalMinutes: { type: "number" },
maxNaggingReminders: { type: "number" },
language: { type: "string", enum: ["en", "de"] },
stockCalculationMode: { type: "string", enum: ["automatic", "manual"] },
shareStockStatus: { type: "boolean" },
upcomingTodayOnly: { type: "boolean" },
shareScheduleTodayOnly: { type: "boolean" },
swapDashboardMainSections: { type: "boolean" },
},
},
response: {
200: { type: "object", properties: { success: { type: "boolean" } } },
401: settingsErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
const body = request.body;
const body = request.body;
// Check if any stock reminders are configured
const hasEmailStock = body.emailEnabled && body.emailStockReminders && body.notificationEmail;
const hasShoutrrrStock = body.shoutrrrEnabled && body.shoutrrrStockReminders && body.shoutrrrUrl;
const hasAnyStockReminder = hasEmailStock || hasShoutrrrStock;
// Check if any stock reminders are configured
const hasEmailStock = body.emailEnabled && body.emailStockReminders && body.notificationEmail;
const hasShoutrrrStock = body.shoutrrrEnabled && body.shoutrrrStockReminders && body.shoutrrrUrl;
const hasAnyStockReminder = hasEmailStock || hasShoutrrrStock;
// Disable repeatDailyReminders if no stock reminders are configured
const repeatDailyReminders = hasAnyStockReminder ? (body.repeatDailyReminders ?? false) : false;
// Disable repeatDailyReminders if no stock reminders are configured
const repeatDailyReminders = hasAnyStockReminder ? (body.repeatDailyReminders ?? false) : false;
// Update or insert user settings
const existingSettings = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
// Update or insert user settings
const existingSettings = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
const settingsData = {
emailEnabled: body.emailEnabled,
notificationEmail: body.notificationEmail || null,
emailStockReminders: body.emailStockReminders ?? true,
emailIntakeReminders: body.emailIntakeReminders ?? true,
emailPrescriptionReminders: body.emailPrescriptionReminders ?? true,
shoutrrrEnabled: body.shoutrrrEnabled ?? false,
shoutrrrUrl: body.shoutrrrUrl || null,
shoutrrrStockReminders: body.shoutrrrStockReminders ?? true,
shoutrrrIntakeReminders: body.shoutrrrIntakeReminders ?? true,
shoutrrrPrescriptionReminders: body.shoutrrrPrescriptionReminders ?? true,
reminderDaysBefore: body.reminderDaysBefore,
repeatDailyReminders,
skipRemindersForTakenDoses: body.skipRemindersForTakenDoses ?? false,
repeatRemindersEnabled: body.repeatRemindersEnabled ?? false,
reminderRepeatIntervalMinutes: body.reminderRepeatIntervalMinutes ?? 30,
maxNaggingReminders: body.maxNaggingReminders ?? 5,
lowStockDays: body.lowStockDays ?? 30,
normalStockDays: body.normalStockDays ?? 90,
highStockDays: body.highStockDays ?? 180,
language: body.language ?? "en",
stockCalculationMode: body.stockCalculationMode ?? "automatic",
shareStockStatus: body.shareStockStatus ?? true,
upcomingTodayOnly: body.upcomingTodayOnly ?? false,
shareScheduleTodayOnly: body.shareScheduleTodayOnly ?? false,
swapDashboardMainSections: body.swapDashboardMainSections ?? false,
updatedAt: new Date(),
};
const settingsData = {
emailEnabled: body.emailEnabled,
notificationEmail: body.notificationEmail || null,
emailStockReminders: body.emailStockReminders ?? true,
emailIntakeReminders: body.emailIntakeReminders ?? true,
emailPrescriptionReminders: body.emailPrescriptionReminders ?? true,
shoutrrrEnabled: body.shoutrrrEnabled ?? false,
shoutrrrUrl: body.shoutrrrUrl || null,
shoutrrrStockReminders: body.shoutrrrStockReminders ?? true,
shoutrrrIntakeReminders: body.shoutrrrIntakeReminders ?? true,
shoutrrrPrescriptionReminders: body.shoutrrrPrescriptionReminders ?? true,
reminderDaysBefore: body.reminderDaysBefore,
repeatDailyReminders,
skipRemindersForTakenDoses: body.skipRemindersForTakenDoses ?? false,
repeatRemindersEnabled: body.repeatRemindersEnabled ?? false,
reminderRepeatIntervalMinutes: body.reminderRepeatIntervalMinutes ?? 30,
maxNaggingReminders: body.maxNaggingReminders ?? 5,
lowStockDays: body.lowStockDays ?? 30,
normalStockDays: body.normalStockDays ?? 90,
highStockDays: body.highStockDays ?? 180,
language: body.language ?? "en",
stockCalculationMode: body.stockCalculationMode ?? "automatic",
shareStockStatus: body.shareStockStatus ?? true,
upcomingTodayOnly: body.upcomingTodayOnly ?? false,
shareScheduleTodayOnly: body.shareScheduleTodayOnly ?? false,
swapDashboardMainSections: body.swapDashboardMainSections ?? false,
updatedAt: new Date(),
};
if (existingSettings.length > 0) {
await db.update(userSettings).set(settingsData).where(eq(userSettings.userId, userId));
} else {
await db.insert(userSettings).values({
userId: userId,
...settingsData,
});
if (existingSettings.length > 0) {
await db.update(userSettings).set(settingsData).where(eq(userSettings.userId, userId));
} else {
await db.insert(userSettings).values({
userId: userId,
...settingsData,
});
}
return reply.send({ success: true });
}
return reply.send({ success: true });
});
);
// Update only the language setting (lightweight, called on dropdown change)
app.put<{ Body: { language: string } }>("/settings/language", async (request, reply) => {
const userId = await getUserId(request, reply);
const { language } = request.body;
app.put<{ Body: { language: string } }>(
"/settings/language",
{
schema: {
tags: ["settings"],
summary: "Update UI language",
security: settingsEndpointSecurity,
body: {
type: "object",
required: ["language"],
properties: {
language: { type: "string", enum: ["en", "de"] },
},
},
response: {
200: { type: "object", properties: { success: { type: "boolean" } } },
400: settingsErrorSchema,
401: settingsErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
const { language } = request.body;
if (!language || !["en", "de"].includes(language)) {
return reply.status(400).send({ error: "Invalid language" });
if (!language || !["en", "de"].includes(language)) {
return reply.status(400).send({ error: "Invalid language" });
}
const existingSettings = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
if (existingSettings.length > 0) {
await db.update(userSettings).set({ language, updatedAt: new Date() }).where(eq(userSettings.userId, userId));
} else {
await db.insert(userSettings).values({
userId,
...getDefaultSettings(),
language,
});
}
return reply.send({ success: true });
}
const existingSettings = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
if (existingSettings.length > 0) {
await db.update(userSettings).set({ language, updatedAt: new Date() }).where(eq(userSettings.userId, userId));
} else {
await db.insert(userSettings).values({
userId,
...getDefaultSettings(),
language,
});
}
return reply.send({ success: true });
});
);
// Test email - use SMTP settings from process.env
app.post<{ Body: TestEmailBody }>("/settings/test-email", async (request, reply) => {
const { email } = request.body;
const smtpHost = process.env.SMTP_HOST;
const smtpUser = process.env.SMTP_USER;
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS;
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
const smtpSecure = process.env.SMTP_SECURE === "true";
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
request.log.info(
{
to: maskEmail(email),
hasSmtpHost: Boolean(smtpHost),
hasSmtpUser: Boolean(smtpUser),
hasSmtpPass: Boolean(smtpPass),
hasSmtpFrom: Boolean(smtpFrom),
smtpPort,
smtpSecure,
},
"[Settings] Test email request received"
);
if (!smtpHost || !smtpUser) {
request.log.warn(
{ to: maskEmail(email), hasSmtpHost: Boolean(smtpHost), hasSmtpUser: Boolean(smtpUser) },
"[Settings] Test email skipped: SMTP not configured"
);
return reply.status(400).send({ error: "SMTP not configured" });
}
try {
const transporter = nodemailer.createTransport({
host: smtpHost,
port: smtpPort,
secure: smtpSecure,
auth: {
user: smtpUser,
pass: smtpPass ?? "",
app.post<{ Body: TestEmailBody }>(
"/settings/test-email",
{
schema: {
tags: ["settings"],
summary: "Send test email",
description: "Sends a test message using configured SMTP settings.",
security: settingsEndpointSecurity,
body: {
type: "object",
required: ["email"],
properties: {
email: { type: "string", format: "email" },
},
},
});
response: {
200: {
type: "object",
properties: {
success: { type: "boolean" },
message: { type: "string" },
},
},
400: settingsErrorSchema,
401: settingsErrorSchema,
500: settingsErrorSchema,
502: settingsErrorSchema,
},
},
},
async (request, reply) => {
const { email } = request.body;
request.log.info({ to: maskEmail(email) }, "[Settings] Sending test email");
const smtpHost = process.env.SMTP_HOST;
const smtpUser = process.env.SMTP_USER;
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS;
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
const smtpSecure = process.env.SMTP_SECURE === "true";
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
const mailResult = await transporter.sendMail({
from: smtpFrom,
to: email,
subject: "MedAssist-ng - Test Email",
text: "This is a test email from MedAssist-ng. If you received this, your email configuration is working correctly!",
html: `
request.log.info(
{
to: maskEmail(email),
hasSmtpHost: Boolean(smtpHost),
hasSmtpUser: Boolean(smtpUser),
hasSmtpPass: Boolean(smtpPass),
hasSmtpFrom: Boolean(smtpFrom),
smtpPort,
smtpSecure,
},
"[Settings] Test email request received"
);
if (!smtpHost || !smtpUser) {
request.log.warn(
{ to: maskEmail(email), hasSmtpHost: Boolean(smtpHost), hasSmtpUser: Boolean(smtpUser) },
"[Settings] Test email skipped: SMTP not configured"
);
return reply.status(400).send({ error: "SMTP not configured" });
}
try {
const transporter = nodemailer.createTransport({
host: smtpHost,
port: smtpPort,
secure: smtpSecure,
auth: {
user: smtpUser,
pass: smtpPass ?? "",
},
});
request.log.info({ to: maskEmail(email) }, "[Settings] Sending test email");
const mailResult = await transporter.sendMail({
from: smtpFrom,
to: email,
subject: "MedAssist-ng - Test Email",
text: "This is a test email from MedAssist-ng. If you received this, your email configuration is working correctly!",
html: `
<div style="font-family: system-ui, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #2563eb;">MedAssist-ng - Test Email</h2>
<p>This is a test email from MedAssist-ng.</p>
@@ -525,55 +681,86 @@ export async function settingsRoutes(app: FastifyInstance) {
<p style="color: #6b7280; font-size: 14px;">Sent from MedAssist-ng Medication Planner</p>
</div>
`,
});
});
const deliveryError = getDeliveryError(mailResult);
if (deliveryError) {
throw new Error(deliveryError);
const deliveryError = getDeliveryError(mailResult);
if (deliveryError) {
throw new Error(deliveryError);
}
request.log.info({ to: maskEmail(email), messageId: mailResult.messageId }, "[Settings] Test email sent");
return reply.send({ success: true, message: "Test email sent successfully" });
} catch (error) {
request.log.error({ error, to: maskEmail(email) }, "[Settings] Test email failed");
const failure = classifyTestEmailFailure(error);
return reply.status(failure.status).send({ error: failure.message, code: failure.code });
}
request.log.info({ to: maskEmail(email), messageId: mailResult.messageId }, "[Settings] Test email sent");
return reply.send({ success: true, message: "Test email sent successfully" });
} catch (error) {
request.log.error({ error, to: maskEmail(email) }, "[Settings] Test email failed");
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return reply.status(500).send({ error: `Failed to send email: ${errorMessage}` });
}
});
);
// Test Shoutrrr/ntfy notification
app.post<{ Body: TestShoutrrrBody }>("/settings/test-shoutrrr", async (request, reply) => {
const { url } = request.body;
app.post<{ Body: TestShoutrrrBody }>(
"/settings/test-shoutrrr",
{
schema: {
tags: ["settings"],
summary: "Send test push notification",
description: "Sends a test notification via a Shoutrrr-compatible URL.",
security: settingsEndpointSecurity,
body: {
type: "object",
required: ["url"],
properties: {
url: { type: "string" },
},
},
response: {
200: {
type: "object",
properties: {
success: { type: "boolean" },
message: { type: "string" },
},
},
400: settingsErrorSchema,
401: settingsErrorSchema,
500: settingsErrorSchema,
},
},
},
async (request, reply) => {
const { url } = request.body;
if (!url) {
return reply.status(400).send({ error: "Notification URL is required" });
}
try {
const provider = getNotificationProvider(url);
const result = await sendShoutrrrNotification(
url,
"MedAssist-ng Test",
"This is a test notification from MedAssist-ng. If you received this, your notification configuration is working correctly!"
);
if (result.success) {
request.log.info({ provider }, "[Settings] Test push notification sent");
return reply.send({ success: true, message: "Test notification sent successfully" });
} else {
request.log.warn({ provider, error: result.error ?? "unknown" }, "[Settings] Test push notification failed");
return reply.status(500).send({ error: result.error });
if (!url) {
return reply.status(400).send({ error: "Notification URL is required" });
}
try {
const provider = getNotificationProvider(url);
const result = await sendShoutrrrNotification(
url,
"MedAssist-ng Test",
"This is a test notification from MedAssist-ng. If you received this, your notification configuration is working correctly!"
);
if (result.success) {
request.log.info({ provider }, "[Settings] Test push notification sent");
return reply.send({ success: true, message: "Test notification sent successfully" });
} else {
request.log.warn({ provider, error: result.error ?? "unknown" }, "[Settings] Test push notification failed");
return reply.status(500).send({ error: result.error });
}
} catch (error) {
request.log.error(
{ provider: getNotificationProvider(url), error },
"[Settings] Unexpected error while sending test push notification"
);
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return reply.status(500).send({ error: `Failed to send notification: ${errorMessage}` });
}
} catch (error) {
request.log.error(
{ provider: getNotificationProvider(url), error },
"[Settings] Unexpected error while sending test push notification"
);
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return reply.status(500).send({ error: `Failed to send notification: ${errorMessage}` });
}
});
);
}
// Validate and sanitize URL to prevent SSRF attacks
+199 -113
View File
@@ -3,9 +3,10 @@ import { and, 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 { doseTracking, medications, shareTokens, userSettings, users } from "../db/schema.js";
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 { isAmountBasedPackageType, normalizePackageType } from "../utils/package-profiles.js";
import {
@@ -23,6 +24,8 @@ const createShareSchema = z.object({
scheduleDays: z.number().int().min(1).max(365).default(30),
});
const shareTokenPattern = /^[a-f0-9]{16}$/;
function maskToken(token: string): string {
if (token.length <= 8) return token;
return `${token.slice(0, 4)}...${token.slice(-4)}`;
@@ -51,119 +54,202 @@ 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) {
request.log.warn(`[Share] Invalid share token requested: ${maskToken(token)}`);
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()) {
request.log.warn(
`[Share] Expired token requested: ${maskToken(token)} (owner=${share.userId}, takenBy=${share.takenBy})`
);
// 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 = isAmountBasedPackageType(med.packageType)
? 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: normalizePackageType(med.packageType),
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,
app.get<{ Params: { token: string } }>(
"/share/:token",
{
config: {
rateLimit: {
max: 60,
timeWindow: "1 minute",
errorResponseBuilder: () => ({ error: "rate_limited" }),
},
},
stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic",
shareStockStatus: settings?.shareStockStatus ?? true,
upcomingTodayOnly: settings?.upcomingTodayOnly ?? false,
shareScheduleTodayOnly: settings?.shareScheduleTodayOnly ?? false,
};
});
},
async (request, reply) => {
const { token } = request.params;
// Find share token
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
if (!share) {
request.log.warn(`[Share] Invalid share token requested: ${maskToken(token)}`);
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()) {
request.log.warn(
`[Share] Expired token requested: ${maskToken(token)} (owner=${share.userId}, takenBy=${share.takenBy})`
);
// 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 = isAmountBasedPackageType(med.packageType)
? 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: normalizePackageType(med.packageType),
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,
upcomingTodayOnly: settings?.upcomingTodayOnly ?? false,
shareScheduleTodayOnly: settings?.shareScheduleTodayOnly ?? false,
};
}
);
// ---------------------------------------------------------------------------
// GET /share/:token/overview - PUBLIC: Read-only medication overview by token
// ---------------------------------------------------------------------------
app.get<{ Params: { token: string } }>(
"/share/:token/overview",
{
config: {
rateLimit: {
max: 60,
timeWindow: "1 minute",
errorResponseBuilder: () => ({ error: "rate_limited" }),
},
},
},
async (request, reply) => {
reply.header("Cache-Control", "no-store");
const { token } = request.params;
if (!shareTokenPattern.test(token)) {
request.log.warn(`[ShareOverview] Rejected invalid token format: ${maskToken(token)}`);
return reply.status(404).send({ error: "not_found" });
}
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
if (!share) {
request.log.warn(`[ShareOverview] Unknown token requested: ${maskToken(token)}`);
return reply.status(404).send({ error: "not_found" });
}
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
request.log.warn(
`[ShareOverview] Expired token requested: ${maskToken(token)} (owner=${share.userId}, takenBy=${share.takenBy})`
);
return reply.status(410).send({
error: "expired",
expiredAt: share.expiresAt.toISOString(),
});
}
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, share.userId));
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
const allMeds = await db.select().from(medications).where(eq(medications.userId, share.userId));
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);
});
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId));
const overview = buildSharedMedicationOverview({
medications: meds,
doses,
thresholdDays: settings?.lowStockDays ?? 30,
shareStockStatus: settings?.shareStockStatus ?? true,
});
return {
takenBy: share.takenBy,
sharedBy: owner?.username ?? null,
generatedAt: new Date().toISOString(),
medications: overview,
};
}
);
// ---------------------------------------------------------------------------
// POST /share - PROTECTED: Create a new share link
+205
View File
@@ -0,0 +1,205 @@
import type { doseTracking, medications } from "../db/schema.js";
import { isAmountBasedPackageType } from "../utils/package-profiles.js";
import {
getTodayInTimezone,
type Intake,
normalizeIntakeUsageForStock,
parseIntakesJson,
parseLocalDateTime,
} from "../utils/scheduler-utils.js";
const MS_PER_DAY = 86_400_000;
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
type MedicationRow = typeof medications.$inferSelect;
type DoseRow = typeof doseTracking.$inferSelect;
export type SharedMedicationOverviewItem = {
name: string;
genericName: string | null;
imageUrl: string | null;
packageType: string;
packCount: number;
blistersPerPack: number;
pillsPerBlister: number;
totalPills: number | null;
looseTablets: number;
currentStock: number | null;
capacity: number | null;
daysLeft: number | null;
nextIntakeDate: string | null;
depletionDate: string | null;
priority: "normal" | "high" | null;
expiryDate: string | null;
medicationStartDate: string | null;
prescriptionEnabled: boolean;
prescriptionRemainingRefills: number | null;
};
function toDateOnlyString(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function parseDateOnly(dateOnly: string): Date {
const [year, month, day] = dateOnly.split("-").map((value) => Number.parseInt(value, 10));
return new Date(year, month - 1, day, 0, 0, 0, 0);
}
function computeCapacity(medication: MedicationRow): number {
if (isAmountBasedPackageType(medication.packageType)) {
return medication.totalPills ?? medication.looseTablets;
}
return medication.packCount * medication.blistersPerPack * medication.pillsPerBlister;
}
function computeDailyDoseRate(intakes: Intake[], medication: MedicationRow): number {
return intakes.reduce((sum, intake) => {
if (intake.every <= 0) return sum;
const normalizedUsage = normalizeIntakeUsageForStock(intake, medication.medicationForm, medication.packageType);
return sum + normalizedUsage / intake.every;
}, 0);
}
function computeNextIntakeDate(intakes: Intake[], todayDateOnly: string): string | null {
const today = parseDateOnly(todayDateOnly);
let nextDate: Date | null = null;
for (const intake of intakes) {
if (intake.every <= 0) continue;
const startDate = parseLocalDateTime(intake.start);
const startDateOnly = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate(), 0, 0, 0, 0);
let candidate = startDateOnly;
if (candidate.getTime() < today.getTime()) {
const elapsedDays = Math.floor((today.getTime() - candidate.getTime()) / MS_PER_DAY);
const intervals = Math.ceil(elapsedDays / intake.every);
candidate = new Date(candidate.getTime() + intervals * intake.every * MS_PER_DAY);
}
if (!nextDate || candidate.getTime() < nextDate.getTime()) {
nextDate = candidate;
}
}
return nextDate ? toDateOnlyString(nextDate) : null;
}
function computeTakenAmount(
medication: MedicationRow,
intakes: Intake[],
dosesByMedication: Map<number, DoseRow[]>
): number {
const doseRows = dosesByMedication.get(medication.id) ?? [];
if (doseRows.length === 0) return 0;
const correctionDateOnlyMs = medication.lastStockCorrectionAt
? new Date(
medication.lastStockCorrectionAt.getFullYear(),
medication.lastStockCorrectionAt.getMonth(),
medication.lastStockCorrectionAt.getDate(),
0,
0,
0,
0
).getTime()
: 0;
let takenAmount = 0;
for (const dose of doseRows) {
if (dose.dismissed) continue;
const match = doseIdPattern.exec(dose.doseId);
if (!match) continue;
const intakeIndex = Number.parseInt(match[2], 10);
const doseDateOnlyMs = Number.parseInt(match[3], 10);
if (Number.isNaN(intakeIndex) || Number.isNaN(doseDateOnlyMs)) continue;
if (doseDateOnlyMs < correctionDateOnlyMs) continue;
const intake = intakes[intakeIndex];
if (!intake) continue;
takenAmount += normalizeIntakeUsageForStock(intake, medication.medicationForm, medication.packageType);
}
return takenAmount;
}
function toNullableDate(value: string | null): string | null {
if (!value) return null;
return value.trim() ? value : null;
}
export function buildSharedMedicationOverview(options: {
medications: MedicationRow[];
doses: DoseRow[];
thresholdDays: number;
shareStockStatus: boolean;
}): SharedMedicationOverviewItem[] {
const { medications: medicationRows, doses, thresholdDays, shareStockStatus } = options;
const dosesByMedication = new Map<number, DoseRow[]>();
for (const dose of doses) {
const match = doseIdPattern.exec(dose.doseId);
if (!match) continue;
const medicationId = Number.parseInt(match[1], 10);
if (Number.isNaN(medicationId)) continue;
const existing = dosesByMedication.get(medicationId) ?? [];
existing.push(dose);
dosesByMedication.set(medicationId, existing);
}
const todayDateOnly = getTodayInTimezone();
const todayDate = parseDateOnly(todayDateOnly);
return medicationRows.map((medication) => {
const intakes = parseIntakesJson(
medication.intakesJson,
{
usageJson: medication.usageJson,
everyJson: medication.everyJson,
startJson: medication.startJson,
},
medication.intakeRemindersEnabled ?? false
);
const capacity = computeCapacity(medication);
const dailyDoseRate = computeDailyDoseRate(intakes, medication);
const takenAmount = computeTakenAmount(medication, intakes, dosesByMedication);
const rawCurrentStock = capacity + (medication.stockAdjustment ?? 0) - takenAmount;
const currentStock = Math.max(0, Math.floor(rawCurrentStock));
const daysLeft = dailyDoseRate > 0 ? Math.floor(currentStock / dailyDoseRate) : null;
const depletionDate =
daysLeft === null ? null : toDateOnlyString(new Date(todayDate.getTime() + daysLeft * MS_PER_DAY));
const priority: "normal" | "high" = daysLeft !== null && daysLeft <= thresholdDays ? "high" : "normal";
return {
name: medication.name,
genericName: medication.genericName,
imageUrl: medication.imageUrl,
packageType: medication.packageType,
packCount: medication.packCount,
blistersPerPack: medication.blistersPerPack,
pillsPerBlister: medication.pillsPerBlister,
totalPills: medication.totalPills,
looseTablets: medication.looseTablets,
currentStock: shareStockStatus ? currentStock : null,
capacity: shareStockStatus ? capacity : null,
daysLeft: shareStockStatus ? daysLeft : null,
nextIntakeDate: computeNextIntakeDate(intakes, todayDateOnly),
depletionDate: shareStockStatus ? depletionDate : null,
priority: shareStockStatus ? priority : null,
expiryDate: toNullableDate(medication.expiryDate),
medicationStartDate: toNullableDate(medication.medicationStartDate),
prescriptionEnabled: medication.prescriptionEnabled ?? false,
prescriptionRemainingRefills: medication.prescriptionRemainingRefills,
};
});
}
@@ -4,7 +4,7 @@ import { and, eq, gte, lte } from "drizzle-orm";
import nodemailer from "nodemailer";
import { db } from "../db/client.js";
import { getDataDir } from "../db/db-utils.js";
import { doseTracking, medications } from "../db/schema.js";
import { doseTracking, medications, users } from "../db/schema.js";
import {
getDateLocale,
getFooterHtml,
@@ -89,6 +89,21 @@ function buildDoseIdForIntake(intake: UpcomingIntake & { medicationId: number; b
return `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}`;
}
async function resolveSchedulerUserDisplayName(userId: number): Promise<string> {
const [userRow] = await db.select({ username: users.username }).from(users).where(eq(users.id, userId)).limit(1);
return userRow?.username?.trim() || `unknown-user-${userId}`;
}
function formatIntakeDescriptor(
definitionIndex: number,
medicationName: string,
medicationId: number,
intake: { every: number; usage: number; start: string; intakeRemindersEnabled: boolean; takenBy: string | null }
): string {
const takenByPart = intake.takenBy ? `, takenBy=${intake.takenBy}` : "";
return `Intake #${definitionIndex + 1} (index=${definitionIndex}, medication=${medicationName}, medicationId=${medicationId}, start=${intake.start}, every=${intake.every}d, usage=${intake.usage}, reminderEnabled=${intake.intakeRemindersEnabled}${takenByPart})`;
}
async function autoMarkDueIntakesAsTaken(
settings: UserSettings & { userId: number },
rows: (typeof medications.$inferSelect)[],
@@ -182,7 +197,7 @@ async function autoMarkDueIntakesAsTaken(
}
if (inserted > 0) {
logger.info(`[IntakeReminder] User ${settings.userId}: Auto-marked ${inserted} due intake dose(s) as taken`);
logger.info(`[IntakeReminder] Auto-marked ${inserted} due intake dose(s) as taken`);
}
return inserted;
@@ -375,9 +390,20 @@ async function checkAndSendIntakeReminders(logger: ServiceLogger): Promise<void>
return; // No users with settings
}
logger.debug(`[IntakeReminder] Found ${allUserSettings.length} users to check`);
const intakeEligibleSettings = allUserSettings.filter((settings) => {
const emailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailIntakeReminders;
const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrIntakeReminders;
return Boolean(emailEnabled || shoutrrrEnabled);
});
for (const userSettings of allUserSettings) {
if (intakeEligibleSettings.length === 0) {
logger.debug("[IntakeReminder] No intake notification channels enabled");
return;
}
logger.debug(`[IntakeReminder] Evaluating ${intakeEligibleSettings.length} intake reminder profile(s)`);
for (const userSettings of intakeEligibleSettings) {
await checkAndSendIntakeRemindersForUser(userSettings, logger);
}
}
@@ -388,10 +414,9 @@ async function checkAndSendIntakeRemindersForUser(
): Promise<void> {
const language = settings.language;
const tr = getTranslations(language);
const schedulerUserName = await resolveSchedulerUserDisplayName(settings.userId);
logger.debug(
`[IntakeReminder] Checking user ${settings.userId} - repeat:${settings.repeatRemindersEnabled} skip:${settings.skipRemindersForTakenDoses}`
);
logger.debug(`[IntakeReminder] Evaluating intake reminder profile for user '${schedulerUserName}'`);
const rows = await db
.select()
@@ -409,14 +434,11 @@ async function checkAndSendIntakeRemindersForUser(
const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrIntakeReminders;
if (!emailEnabled && !shoutrrrEnabled) {
logger.debug(
`[IntakeReminder] User ${settings.userId}: No intake notifications enabled (email:${emailEnabled}, shoutrrr:${shoutrrrEnabled})`
);
return; // No intake reminder notifications enabled for this user
}
logger.debug(
`[IntakeReminder] User ${settings.userId}: Notifications enabled (email:${emailEnabled}, shoutrrr:${shoutrrrEnabled})`
`[IntakeReminder] Notifications enabled for current scheduler context (email:${emailEnabled}, shoutrrr:${shoutrrrEnabled})`
);
// Build medication entries that have at least one reminder-enabled intake.
@@ -434,25 +456,26 @@ async function checkAndSendIntakeRemindersForUser(
.filter((entry) => entry.intakesWithReminders.length > 0);
if (reminderEntries.length === 0) {
logger.debug(`[IntakeReminder] User ${settings.userId}: No medications have reminders enabled`);
logger.debug("[IntakeReminder] No medications have reminders enabled for current scheduler context");
return; // No medications have reminders enabled for this user
}
logger.debug(`[IntakeReminder] User ${settings.userId}: Found ${reminderEntries.length} medications with reminders`);
logger.debug(`[IntakeReminder] Found ${reminderEntries.length} medications with reminders`);
const state = loadIntakeReminderState();
const allUpcoming: (UpcomingIntake & { medicationId: number; blisterIndex: number })[] = [];
let scheduledIntakesTodayCount = 0;
// Get start and end of today in user's timezone (for filtering today's doses only)
const now = new Date();
const checkMinuteStart = new Date(Math.floor(now.getTime() / 60000) * 60000);
const checkMinuteEnd = new Date(checkMinuteStart.getTime() + 60000);
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
todayStart.setHours(0, 0, 0, 0);
const todayEnd = new Date(now.toLocaleString("en-US", { timeZone: tz }));
todayEnd.setHours(23, 59, 59, 999);
logger.debug(
`[IntakeReminder] User ${settings.userId}: Today range: ${todayStart.toISOString()} to ${todayEnd.toISOString()}`
);
logger.debug(`[IntakeReminder] Today range: ${todayStart.toISOString()} to ${todayEnd.toISOString()}`);
// Find intakes: upcoming ones in reminder window + past ones for repeat reminders
for (const { med, intakes, intakesWithReminders } of reminderEntries) {
@@ -461,15 +484,26 @@ async function checkAndSendIntakeRemindersForUser(
const medDisplayName = med.name || med.genericName || "";
logger.debug(
`[IntakeReminder] User ${settings.userId}: Processing medication "${medDisplayName}" with ${intakes.length} intakes`
`[IntakeReminder] Processing medication '${medDisplayName}' (id=${med.id}) with ${intakes.length} intake definition(s)`
);
// Process each intake separately to track blisterIndex
intakesWithReminders.forEach((intake, _blisterIndex) => {
const actualIndex = intakes.indexOf(intake); // Get the actual index in original array
logger.debug(
`[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} - start: ${intake.start}, every: ${intake.every} days, usage: ${intake.usage}, takenBy: ${intake.takenBy || "(none)"}`
const intakeDescriptor = formatIntakeDescriptor(actualIndex, medDisplayName, med.id, intake);
logger.debug(`[IntakeReminder] ${intakeDescriptor}`);
const todaysIntakesForThisDefinition = getTodaysIntakes(
medDisplayName,
[intake],
medicationTakenBy,
med.pillWeightMg,
locale,
tz,
med.id,
med.doseUnit ?? "mg"
);
scheduledIntakesTodayCount += todaysIntakesForThisDefinition.length;
// Always get upcoming intakes (15 min before) for first reminders
const upcomingIntakes = getUpcomingIntakes(
@@ -485,7 +519,10 @@ async function checkAndSendIntakeRemindersForUser(
med.doseUnit ?? "mg"
);
logger.debug(
`[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} found ${upcomingIntakes.length} upcoming intakes (reminder window)`
`[IntakeReminder] ${intakeDescriptor} -> ${upcomingIntakes.length} intake(s) currently due for advance reminder (default ${REMINDER_MINUTES_BEFORE} min before intake, with catch-up while intake is still in the future)`
);
logger.debug(
`[IntakeReminder] ${intakeDescriptor} -> ${todaysIntakesForThisDefinition.length} scheduled intake(s) today (independent of reminder window)`
);
// Add upcoming intakes for first reminders
@@ -499,24 +536,14 @@ async function checkAndSendIntakeRemindersForUser(
// If repeat reminders enabled, also check for missed intakes (past the intake time)
if (settings.repeatRemindersEnabled) {
const allTodaysIntakes = getTodaysIntakes(
medDisplayName,
[intake],
medicationTakenBy,
med.pillWeightMg,
locale,
tz,
med.id,
med.doseUnit ?? "mg"
);
logger.debug(
`[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} - all today's intakes: ${allTodaysIntakes.length}, times: ${allTodaysIntakes.map((i) => i.intakeTime.toISOString()).join(", ")}`
`[IntakeReminder] ${intakeDescriptor} -> ${todaysIntakesForThisDefinition.length} candidate intake(s) for repeat reminders`
);
const missedIntakes = allTodaysIntakes.filter(
const missedIntakes = todaysIntakesForThisDefinition.filter(
(todayIntake) => todayIntake.intakeTime.getTime() < now.getTime()
);
logger.debug(
`[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} found ${missedIntakes.length} missed intakes (past intake time)`
`[IntakeReminder] ${intakeDescriptor} -> ${missedIntakes.length} missed intake(s) (past intake time)`
);
// Add missed intakes for repeat reminders (only if not already in upcoming list)
@@ -534,10 +561,13 @@ async function checkAndSendIntakeRemindersForUser(
});
}
logger.debug(`[IntakeReminder] User ${settings.userId}: Total ${allUpcoming.length} intakes for today`);
logger.debug(`[IntakeReminder] Total scheduled intakes for today: ${scheduledIntakesTodayCount}`);
logger.debug(`[IntakeReminder] Total reminder candidates in current check: ${allUpcoming.length}`);
if (allUpcoming.length === 0) {
logger.debug(`[IntakeReminder] User ${settings.userId}: No intakes for today`);
logger.debug(
`[IntakeReminder] No reminder due in this check window (minute=${checkMinuteStart.toISOString()}..${checkMinuteEnd.toISOString()}, advanceLead=${REMINDER_MINUTES_BEFORE}m, plus catch-up while intake is still future)`
);
return; // No upcoming intakes for today
}
@@ -569,7 +599,7 @@ async function checkAndSendIntakeRemindersForUser(
// Send a catch-up reminder (counts as first nagging reminder).
remindersToSend.push({ ...intake, currentSendCount: 1, maxReminders, isAdvanceReminder: false });
logger.info(
`[IntakeReminder] User ${settings.userId}: Catch-up reminder for recently missed "${intake.medName}" at ${intake.intakeTimeStr} (${Math.round(minutesSinceIntake)} min ago)`
`[IntakeReminder] Catch-up reminder for recently missed intake (${Math.round(minutesSinceIntake)} min ago)`
);
} else {
// Long ago — seed state without notification (user likely already noticed)
@@ -580,15 +610,13 @@ async function checkAndSendIntakeRemindersForUser(
advanceSent: false,
};
logger.debug(
`[IntakeReminder] User ${settings.userId}: Seeding state for old past "${intake.medName}" at ${intake.intakeTimeStr} (no notification — ${Math.round(minutesSinceIntake)} min ago)`
`[IntakeReminder] Seeding state for old past intake (no notification — ${Math.round(minutesSinceIntake)} min ago)`
);
}
} else {
// Upcoming - this is advance reminder (no counter)
remindersToSend.push({ ...intake, currentSendCount: 0, maxReminders, isAdvanceReminder: true });
logger.debug(
`[IntakeReminder] User ${settings.userId}: Advance reminder for "${intake.medName}" at ${intake.intakeTimeStr}`
);
logger.debug("[IntakeReminder] Advance reminder candidate added");
}
} else if (settings.repeatRemindersEnabled && isIntakePast) {
// Intake time passed - check if we need to send nagging reminder
@@ -601,15 +629,11 @@ async function checkAndSendIntakeRemindersForUser(
if (currentNaggingCount >= maxReminders) {
// Max nagging reminders reached - stop
logger.debug(
`[IntakeReminder] User ${settings.userId}: Max nagging (${maxReminders}) reached for "${intake.medName}" at ${intake.intakeTimeStr}`
);
logger.debug(`[IntakeReminder] Max nagging (${maxReminders}) reached for intake reminder key`);
} else if (timeSinceLastReminder >= intervalMs) {
const nextSendCount = currentNaggingCount + 1;
remindersToSend.push({ ...intake, currentSendCount: nextSendCount, maxReminders, isAdvanceReminder: false });
logger.debug(
`[IntakeReminder] User ${settings.userId}: Nagging reminder for "${intake.medName}" at ${intake.intakeTimeStr} (${nextSendCount}/${maxReminders})`
);
logger.debug(`[IntakeReminder] Nagging reminder candidate added (${nextSendCount}/${maxReminders})`);
}
}
// Else: Already sent and either repeats disabled or intake not yet past - skip
@@ -647,9 +671,7 @@ async function checkAndSendIntakeRemindersForUser(
const doseId = `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}-${intake.takenBy}`;
const isTaken = takenDoseIds.has(doseId);
if (isTaken) {
logger.debug(
`[IntakeReminder] User ${settings.userId}: Skipping "${intake.medName}" - dose ${doseId} already taken`
);
logger.debug("[IntakeReminder] Skipping reminder candidate - dose already taken");
}
return !isTaken;
} else {
@@ -657,21 +679,19 @@ async function checkAndSendIntakeRemindersForUser(
const doseId = `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}`;
const isTaken = takenDoseIds.has(doseId);
if (isTaken) {
logger.debug(
`[IntakeReminder] User ${settings.userId}: Skipping "${intake.medName}" - dose ${doseId} already taken`
);
logger.debug("[IntakeReminder] Skipping reminder candidate - dose already taken");
}
return !isTaken;
}
});
if (remindersToSend.length === 0) {
logger.debug(`[IntakeReminder] User ${settings.userId}: All doses taken, skipping reminders`);
logger.debug("[IntakeReminder] All doses taken, skipping reminders");
return;
}
}
logger.info(`[IntakeReminder] User ${settings.userId}: Sending reminder for ${remindersToSend.length} intakes...`);
logger.info(`[IntakeReminder] Sending reminder for ${remindersToSend.length} intakes...`);
// Determine if this is a repeat reminder:
// - Any intake already has a state entry AND is past (repeat after first reminder)
@@ -703,11 +723,9 @@ async function checkAndSendIntakeRemindersForUser(
);
emailSuccess = result.success;
if (result.success) {
logger.info(
`[IntakeReminder] User ${settings.userId}: Email sent successfully (to: ${settings.notificationEmail}, messageId: ${result.messageId}, smtp: ${result.smtpResponse})`
);
logger.info("[IntakeReminder] Email sent successfully");
} else {
logger.error(`[IntakeReminder] User ${settings.userId}: Failed to send email: ${result.error}`);
logger.error(`[IntakeReminder] Failed to send email: ${result.error}`);
}
}
@@ -771,9 +789,9 @@ async function checkAndSendIntakeRemindersForUser(
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
shoutrrrSuccess = result.success;
if (result.success) {
logger.info(`[IntakeReminder] User ${settings.userId}: Push notification sent successfully`);
logger.info("[IntakeReminder] Push notification sent successfully");
} else {
logger.error(`[IntakeReminder] User ${settings.userId}: Failed to send push: ${result.error}`);
logger.error(`[IntakeReminder] Failed to send push: ${result.error}`);
}
}
+9 -19
View File
@@ -687,12 +687,10 @@ async function checkAndSendReminderForUser(
if (!state.notifiedMedications.includes(userStockNotifiedKey) || settings.repeatDailyReminders) {
const stockSendLock = acquireReminderSendLock(userStockNotifiedKey);
if (!stockSendLock) {
logger.debug(`[Reminder] User ${settings.userId}: stock reminder lock already held, skipping duplicate send`);
logger.debug("[Reminder] Stock reminder lock already held, skipping duplicate send");
} else {
try {
logger.info(
`[Reminder] User ${settings.userId}: Sending stock reminder for ${allLowStock.length} medications...`
);
logger.info(`[Reminder] Sending stock reminder for ${allLowStock.length} medications...`);
let emailSuccess = false;
let shoutrrrSuccess = false;
@@ -706,7 +704,7 @@ async function checkAndSendReminderForUser(
);
emailSuccess = result.success;
if (!result.success) {
logger.error(`[Reminder] User ${settings.userId}: Failed to send stock email: ${result.error}`);
logger.error(`[Reminder] Failed to send stock email: ${result.error}`);
}
}
@@ -748,7 +746,7 @@ async function checkAndSendReminderForUser(
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
shoutrrrSuccess = result.success;
if (!result.success) {
logger.error(`[Reminder] User ${settings.userId}: Failed to send stock push: ${result.error}`);
logger.error(`[Reminder] Failed to send stock push: ${result.error}`);
}
}
@@ -780,9 +778,7 @@ async function checkAndSendReminderForUser(
if (!state.notifiedMedications.includes(userPrescriptionNotifiedKey) || settings.repeatDailyReminders) {
const prescriptionSendLock = acquireReminderSendLock(userPrescriptionNotifiedKey);
if (!prescriptionSendLock) {
logger.debug(
`[Reminder] User ${settings.userId}: prescription reminder lock already held, skipping duplicate send`
);
logger.debug("[Reminder] Prescription reminder lock already held, skipping duplicate send");
} else {
try {
// Re-check using fresh state after acquiring lock and pre-mark today as notified.
@@ -791,9 +787,7 @@ async function checkAndSendReminderForUser(
const alreadyNotified = lockedState.notifiedMedications.includes(userPrescriptionNotifiedKey);
const shouldSend = !alreadyNotified || settings.repeatDailyReminders;
if (!shouldSend) {
logger.debug(
`[Reminder] User ${settings.userId}: prescription reminder already marked as sent today, skipping`
);
logger.debug("[Reminder] Prescription reminder already marked as sent today, skipping");
}
const preMarkedNotified =
@@ -813,9 +807,7 @@ async function checkAndSendReminderForUser(
}
if (shouldSend) {
logger.info(
`[Reminder] User ${settings.userId}: Sending prescription reminder for ${allPrescriptionLow.length} medications...`
);
logger.info(`[Reminder] Sending prescription reminder for ${allPrescriptionLow.length} medications...`);
const emptyRx = allPrescriptionLow.filter((m) => m.remainingRefills <= 0);
const lowRx = allPrescriptionLow.filter((m) => m.remainingRefills > 0);
@@ -947,9 +939,7 @@ async function checkAndSendReminderForUser(
emailSuccess = true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
logger.error(
`[Reminder] User ${settings.userId}: Failed to send prescription email: ${errorMessage}`
);
logger.error(`[Reminder] Failed to send prescription email: ${errorMessage}`);
}
}
}
@@ -986,7 +976,7 @@ async function checkAndSendReminderForUser(
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
shoutrrrSuccess = result.success;
if (!result.success) {
logger.error(`[Reminder] User ${settings.userId}: Failed to send prescription push: ${result.error}`);
logger.error(`[Reminder] Failed to send prescription push: ${result.error}`);
}
}
+2 -2
View File
@@ -228,7 +228,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
});
expect(response.statusCode).toBe(400);
expect(response.json().code).toBe("VALIDATION_ERROR");
expect(response.json().code).toBe("FST_ERR_VALIDATION");
});
it("should reject short username", async () => {
@@ -242,7 +242,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
});
expect(response.statusCode).toBe(400);
expect(response.json().code).toBe("VALIDATION_ERROR");
expect(response.json().code).toBe("FST_ERR_VALIDATION");
});
it("should register with trimmed username when input has whitespace", async () => {
@@ -0,0 +1,485 @@
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import cookie from "@fastify/cookie";
import jwt from "@fastify/jwt";
import sensible from "@fastify/sensible";
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";
const { testClient, testDb, mockedEnv } = vi.hoisted(() => {
const { createClient } = require("@libsql/client");
const { drizzle } = require("drizzle-orm/libsql");
const client = createClient({ url: ":memory:" });
const db = drizzle(client);
return {
testClient: client,
testDb: db,
mockedEnv: {
AUTH_ENABLED: true,
REGISTRATION_ENABLED: true,
FORM_LOGIN_ENABLED: true,
OIDC_ENABLED: false,
OIDC_PROVIDER_NAME: "SSO",
NODE_ENV: "test",
LOG_LEVEL: "silent",
PORT: 3000,
CORS_ORIGINS: "*",
JWT_SECRET: "test-jwt-secret",
REFRESH_SECRET: "test-refresh-secret",
COOKIE_SECRET: "test-cookie-secret",
ACCESS_TOKEN_TTL_MINUTES: 15,
REFRESH_TOKEN_TTL_DAYS: 7,
OPENAPI_DOCS_ENABLED: false,
},
};
});
vi.mock("../db/client.js", () => ({
db: testDb,
migrationsReady: Promise.resolve(),
}));
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
const { medicationRoutes } = await import("../routes/medications.js");
const { doseRoutes } = await import("../routes/doses.js");
const { refillRoutes } = await import("../routes/refills.js");
const { shareRoutes } = await import("../routes/share.js");
const { reportRoutes } = await import("../routes/report.js");
const { exportRoutes } = await import("../routes/export.js");
const { hashApiKeyToken } = await import("../plugins/auth.js");
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const migrationsFolder = resolve(__dirname, "../../drizzle");
async function clearTables() {
await testClient.execute("DELETE FROM refill_history");
await testClient.execute("DELETE FROM dose_tracking");
await testClient.execute("DELETE FROM share_tokens");
await testClient.execute("DELETE FROM user_settings");
await testClient.execute("DELETE FROM medications");
await testClient.execute("DELETE FROM api_keys");
await testClient.execute("DELETE FROM refresh_tokens");
await testClient.execute("DELETE FROM users");
}
async function createUser(username: string) {
const result = await testClient.execute({
sql: "INSERT INTO users (username, auth_provider, is_active) VALUES (?, 'local', 1) RETURNING id",
args: [username],
});
return Number(result.rows[0].id);
}
function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
const token = app.jwt.sign({ sub: userId, username });
return `access_token=${token}`;
}
async function insertApiKey(options: {
userId: number;
token: string;
scope?: "read" | "write";
isActive?: boolean;
expiresAt?: Date | null;
}) {
const expiresAtValue = options.expiresAt ? Math.floor(options.expiresAt.getTime() / 1000) : null;
await testClient.execute({
sql: `INSERT INTO api_keys (user_id, name, key_hash, token_prefix, scope, is_active, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
args: [
options.userId,
"Seeded Key",
hashApiKeyToken(options.token),
`${options.token.slice(0, 12)}...`,
options.scope ?? "write",
options.isActive === false ? 0 : 1,
expiresAtValue,
],
});
}
async function seedMedication(options: {
userId: number;
name: string;
takenBy?: string[];
packCount?: number;
looseTablets?: number;
start?: string;
}) {
const start = options.start ?? "2026-01-01T08:00:00.000Z";
const takenBy = options.takenBy ?? ["Daniel"];
const result = await testClient.execute({
sql: `INSERT INTO medications (
user_id, name, generic_name, taken_by_json, medication_form, package_type,
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
usage_json, every_json, start_json, intakes_json,
stock_adjustment, intake_reminders_enabled
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
args: [
options.userId,
options.name,
`${options.name} Generic`,
JSON.stringify(takenBy),
"tablet",
"blister",
options.packCount ?? 1,
1,
10,
options.looseTablets ?? 0,
JSON.stringify([1]),
JSON.stringify([1]),
JSON.stringify([start]),
JSON.stringify([
{
usage: 1,
every: 1,
start,
takenBy: takenBy[0] ?? null,
intakeRemindersEnabled: true,
},
]),
0,
1,
],
});
return Number(result.rows[0].id);
}
async function seedDose(options: { userId: number; doseId: string; dismissed?: boolean }) {
await testClient.execute({
sql: "INSERT INTO dose_tracking (user_id, dose_id, dismissed) VALUES (?, ?, ?)",
args: [options.userId, options.doseId, options.dismissed ? 1 : 0],
});
}
async function seedRefill(options: {
userId: number;
medicationId: number;
packsAdded?: number;
loosePillsAdded?: number;
}) {
await testClient.execute({
sql: `INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription)
VALUES (?, ?, ?, ?, 0)`,
args: [options.medicationId, options.userId, options.packsAdded ?? 1, options.loosePillsAdded ?? 0],
});
}
function buildMedicationPayload(name: string) {
return {
name,
genericName: `${name} Generic`,
takenBy: ["Daniel"],
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
blisters: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00.000Z" }],
};
}
function buildImportPayload() {
return {
version: "1.3",
exportedAt: new Date().toISOString(),
includeSensitiveData: false,
medications: [],
doseHistory: [],
refillHistory: [],
settings: {
emailEnabled: false,
emailStockReminders: true,
emailIntakeReminders: true,
emailPrescriptionReminders: true,
shoutrrrStockReminders: true,
shoutrrrIntakeReminders: true,
shoutrrrPrescriptionReminders: true,
reminderDaysBefore: 7,
repeatDailyReminders: false,
skipRemindersForTakenDoses: false,
repeatRemindersEnabled: false,
reminderRepeatIntervalMinutes: 30,
maxNaggingReminders: 5,
lowStockDays: 30,
normalStockDays: 90,
highStockDays: 180,
language: "en",
stockCalculationMode: "automatic",
shareStockStatus: true,
},
shareLinks: [],
};
}
describe("Real business route authz contracts", () => {
let app: FastifyInstance;
beforeAll(async () => {
await migrate(testDb, { migrationsFolder });
await runAlterMigrations(testClient);
app = Fastify({ logger: false });
await app.register(sensible);
await app.register(cookie, { secret: "test-cookie-secret" });
await app.register(jwt, {
secret: "test-jwt-secret",
cookie: { cookieName: "access_token", signed: false },
});
await app.register(medicationRoutes);
await app.register(doseRoutes);
await app.register(refillRoutes);
await app.register(shareRoutes);
await app.register(reportRoutes);
await app.register(exportRoutes);
await app.ready();
});
afterAll(async () => {
await app.close();
testClient.close();
});
beforeEach(async () => {
vi.clearAllMocks();
await clearTables();
});
it("rejects protected business endpoints without authentication", async () => {
const endpoints: Array<{
method: "GET" | "POST";
url: string;
payload?: Record<string, unknown>;
}> = [
{ method: "GET", url: "/medications" },
{ method: "GET", url: "/doses/taken" },
{ method: "POST", url: "/share", payload: { takenBy: "Daniel", scheduleDays: 7 } },
{ method: "GET", url: "/export" },
{ method: "POST", url: "/medications/report-data", payload: { medicationIds: [1] } },
{ method: "POST", url: "/medications/1/refill", payload: { packsAdded: 1, loosePillsAdded: 0 } },
];
for (const endpoint of endpoints) {
const response = await app.inject({ method: endpoint.method, url: endpoint.url, payload: endpoint.payload });
expect(response.statusCode, `${endpoint.method} ${endpoint.url}`).toBe(401);
expect(response.json()).toMatchObject({ code: "AUTH_REQUIRED" });
}
});
it("scopes medication listing and export output to the authenticated user", async () => {
const ownerId = await createUser("owner-medications");
const otherId = await createUser("other-medications");
const ownerCookie = buildSessionCookie(app, ownerId, "owner-medications");
await seedMedication({ userId: ownerId, name: "Owner Only Med" });
await seedMedication({ userId: otherId, name: "Other User Med" });
const listResponse = await app.inject({
method: "GET",
url: "/medications",
headers: { cookie: ownerCookie },
});
expect(listResponse.statusCode).toBe(200);
expect(listResponse.body).toContain("Owner Only Med");
expect(listResponse.body).not.toContain("Other User Med");
const exportResponse = await app.inject({
method: "GET",
url: "/export",
headers: { cookie: ownerCookie },
});
expect(exportResponse.statusCode).toBe(200);
expect(exportResponse.body).toContain("Owner Only Med");
expect(exportResponse.body).not.toContain("Other User Med");
});
it("returns 404 when a user updates or deletes another user's medication", async () => {
const ownerId = await createUser("owner-update");
const otherId = await createUser("other-update");
const otherCookie = buildSessionCookie(app, otherId, "other-update");
const medicationId = await seedMedication({ userId: ownerId, name: "Protected Medication" });
const updateResponse = await app.inject({
method: "PUT",
url: `/medications/${medicationId}`,
headers: { cookie: otherCookie },
payload: buildMedicationPayload("Updated By Stranger"),
});
expect(updateResponse.statusCode).toBe(404);
const deleteResponse = await app.inject({
method: "DELETE",
url: `/medications/${medicationId}`,
headers: { cookie: otherCookie },
});
expect(deleteResponse.statusCode).toBe(404);
const dbState = await testClient.execute({
sql: "SELECT name FROM medications WHERE id = ?",
args: [medicationId],
});
expect(dbState.rows).toEqual([expect.objectContaining({ name: "Protected Medication" })]);
});
it("scopes dose reads and writes to the authenticated user", async () => {
const ownerId = await createUser("owner-dose");
const otherId = await createUser("other-dose");
const ownerCookie = buildSessionCookie(app, ownerId, "owner-dose");
const otherCookie = buildSessionCookie(app, otherId, "other-dose");
await seedDose({ userId: ownerId, doseId: "101-0-1760000000000" });
await seedDose({ userId: otherId, doseId: "202-0-1760000000000" });
const listResponse = await app.inject({
method: "GET",
url: "/doses/taken",
headers: { cookie: ownerCookie },
});
expect(listResponse.statusCode).toBe(200);
expect(listResponse.body).toContain("101-0-1760000000000");
expect(listResponse.body).not.toContain("202-0-1760000000000");
const deleteResponse = await app.inject({
method: "DELETE",
url: "/doses/taken/101-0-1760000000000",
headers: { cookie: otherCookie },
});
expect(deleteResponse.statusCode).toBe(200);
const ownerDose = await testClient.execute({
sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
args: [ownerId, "101-0-1760000000000"],
});
expect(Number(ownerDose.rows[0].count)).toBe(1);
});
it("enforces medication ownership on refill history and report generation", async () => {
const ownerId = await createUser("owner-refill");
const otherId = await createUser("other-refill");
const otherCookie = buildSessionCookie(app, otherId, "other-refill");
const medicationId = await seedMedication({ userId: ownerId, name: "Owner Refill Med", packCount: 2 });
await seedRefill({ userId: ownerId, medicationId });
const refillListResponse = await app.inject({
method: "GET",
url: `/medications/${medicationId}/refills`,
headers: { cookie: otherCookie },
});
expect(refillListResponse.statusCode).toBe(404);
const refillMutationResponse = await app.inject({
method: "POST",
url: `/medications/${medicationId}/refill`,
headers: { cookie: otherCookie },
payload: { packsAdded: 1, loosePillsAdded: 0 },
});
expect(refillMutationResponse.statusCode).toBe(404);
const reportResponse = await app.inject({
method: "POST",
url: "/medications/report-data",
headers: { cookie: otherCookie },
payload: { medicationIds: [medicationId] },
});
expect(reportResponse.statusCode).toBe(403);
expect(reportResponse.json()).toMatchObject({ error: "Access denied to medication" });
});
it("scopes share people to the authenticated user's medications", async () => {
const ownerId = await createUser("owner-share");
const otherId = await createUser("other-share");
const ownerCookie = buildSessionCookie(app, ownerId, "owner-share");
await seedMedication({ userId: ownerId, name: "Daniel Med", takenBy: ["Daniel"] });
await seedMedication({ userId: otherId, name: "Anna Med", takenBy: ["Anna"] });
const response = await app.inject({
method: "GET",
url: "/share/people",
headers: { cookie: ownerCookie },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ people: ["Daniel"] });
});
it("rejects mutation routes for read-only API keys across business endpoints", async () => {
const userId = await createUser("readonly-business-key");
const medicationId = await seedMedication({ userId, name: "Readonly Med" });
const apiToken = "ma_readonly_business_routes_123456789";
await insertApiKey({ userId, token: apiToken, scope: "read" });
const responses = await Promise.all([
app.inject({
method: "POST",
url: "/medications",
headers: { authorization: `Bearer ${apiToken}` },
payload: buildMedicationPayload("Blocked Create"),
}),
app.inject({
method: "POST",
url: "/doses/taken",
headers: { authorization: `Bearer ${apiToken}` },
payload: { doseId: "1-0-1760000000000" },
}),
app.inject({
method: "POST",
url: `/medications/${medicationId}/refill`,
headers: { authorization: `Bearer ${apiToken}` },
payload: { packsAdded: 1, loosePillsAdded: 0 },
}),
app.inject({
method: "POST",
url: "/share",
headers: { authorization: `Bearer ${apiToken}` },
payload: { takenBy: "Daniel", scheduleDays: 7 },
}),
app.inject({
method: "POST",
url: "/import",
headers: { authorization: `Bearer ${apiToken}` },
payload: buildImportPayload(),
}),
]);
for (const response of responses) {
expect(response.statusCode).toBe(403);
expect(response.json()).toMatchObject({ code: "API_KEY_SCOPE_FORBIDDEN" });
}
});
it("allows read-only API keys to use read endpoints while keeping data scoped to the key owner", async () => {
const userId = await createUser("readonly-export-user");
const otherId = await createUser("readonly-export-other");
await seedMedication({ userId, name: "Readable Owner Med" });
await seedMedication({ userId: otherId, name: "Unreadable Other Med" });
const apiToken = "ma_readonly_export_access_123456789";
await insertApiKey({ userId, token: apiToken, scope: "read" });
const response = await app.inject({
method: "GET",
url: "/export",
headers: { authorization: `Bearer ${apiToken}` },
});
expect(response.statusCode).toBe(200);
expect(response.body).toContain("Readable Owner Med");
expect(response.body).not.toContain("Unreadable Other Med");
});
});
+249 -386
View File
@@ -1,487 +1,333 @@
/**
* Tests for /doses/taken API endpoints.
* Tests marking doses as taken, listing taken doses, and unmarking.
*/
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { buildTestApp, clearTestData, closeTestApp, createTestUser, type TestContext } from "./setup.js";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import cookie from "@fastify/cookie";
import jwt from "@fastify/jwt";
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";
// =============================================================================
// Route Registration
// Since we can't easily import routes that depend on the global db,
// we'll create simplified route handlers for testing the core logic.
// =============================================================================
const { testClient, testDb, mockedEnv } = vi.hoisted(() => {
const { createClient } = require("@libsql/client");
const { drizzle } = require("drizzle-orm/libsql");
const client = createClient({ url: ":memory:" });
const db = drizzle(client);
async function registerDoseRoutes(ctx: TestContext) {
const { app, client } = ctx;
return {
testClient: client,
testDb: db,
mockedEnv: {
AUTH_ENABLED: true,
REGISTRATION_ENABLED: true,
FORM_LOGIN_ENABLED: true,
OIDC_ENABLED: false,
OIDC_PROVIDER_NAME: "SSO",
NODE_ENV: "test",
LOG_LEVEL: "silent",
PORT: 3000,
CORS_ORIGINS: "*",
JWT_SECRET: "test-jwt-secret",
REFRESH_SECRET: "test-refresh-secret",
COOKIE_SECRET: "test-cookie-secret",
ACCESS_TOKEN_TTL_MINUTES: 15,
REFRESH_TOKEN_TTL_DAYS: 7,
OPENAPI_DOCS_ENABLED: false,
},
};
});
// GET /doses/taken - List all taken doses
app.get("/doses/taken", async (_request, _reply) => {
// In test mode, use user ID 1 (will be created in tests)
const userId = 1;
vi.mock("../db/client.js", () => ({
db: testDb,
migrationsReady: Promise.resolve(),
}));
const result = await client.execute({
sql: `SELECT dose_id, taken_at, marked_by FROM dose_tracking WHERE user_id = ?`,
args: [userId],
});
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
return {
doses: result.rows.map((d) => ({
doseId: d.dose_id,
takenAt: (d.taken_at as number) * 1000, // Convert to ms
markedBy: d.marked_by,
})),
};
const { doseRoutes } = await import("../routes/doses.js");
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const migrationsFolder = resolve(__dirname, "../../drizzle");
async function clearTables() {
await testClient.execute("DELETE FROM dose_tracking");
await testClient.execute("DELETE FROM share_tokens");
await testClient.execute("DELETE FROM api_keys");
await testClient.execute("DELETE FROM refresh_tokens");
await testClient.execute("DELETE FROM medications");
await testClient.execute("DELETE FROM user_settings");
await testClient.execute("DELETE FROM users");
}
async function createUser(username: string) {
const result = await testClient.execute({
sql: "INSERT INTO users (username, auth_provider, is_active) VALUES (?, 'local', 1) RETURNING id",
args: [username],
});
// POST /doses/taken - Mark a dose as taken
app.post<{ Body: { doseId: string } }>("/doses/taken", async (request, reply) => {
const userId = 1;
const { doseId } = request.body || {};
return Number(result.rows[0].id);
}
if (!doseId || typeof doseId !== "string" || doseId.length === 0) {
return reply.status(400).send({ error: "doseId is required" });
}
function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
const token = app.jwt.sign({ sub: userId, username });
return `access_token=${token}`;
}
// Check if already marked
const existing = await client.execute({
sql: `SELECT id FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
args: [userId, doseId],
});
if (existing.rows.length > 0) {
return { success: true, message: "Already marked" };
}
// Insert new record
await client.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, NULL)`,
args: [userId, doseId],
});
return { success: true };
});
// DELETE /doses/taken/:doseId - Unmark a dose
app.delete<{ Params: { doseId: string } }>("/doses/taken/:doseId", async (request, _reply) => {
const userId = 1;
const { doseId } = request.params;
// Check if this dose was also dismissed
const existing = await client.execute({
sql: `SELECT id, dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
args: [userId, doseId],
});
if (existing.rows.length > 0 && existing.rows[0].dismissed) {
// Already dismissed - keep the record as-is (don't delete)
// The dose stays dismissed, we just ignore the undo request
} else {
// Not dismissed - delete the record entirely
await client.execute({
sql: `DELETE FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
args: [userId, doseId],
});
}
return { success: true };
});
// POST /doses/dismiss - Dismiss missed doses without deducting stock
app.post<{ Body: { doseIds: string[] } }>("/doses/dismiss", async (request, reply) => {
const userId = 1;
const { doseIds } = request.body || {};
if (!doseIds || !Array.isArray(doseIds) || doseIds.length === 0) {
return reply.status(400).send({ error: "doseIds array is required" });
}
let dismissedCount = 0;
for (const doseId of doseIds) {
// Check if already exists
const existing = await client.execute({
sql: `SELECT id, dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
args: [userId, doseId],
});
if (existing.rows.length > 0) {
// Update to dismissed if not already
if (!existing.rows[0].dismissed) {
await client.execute({
sql: `UPDATE dose_tracking SET dismissed = 1 WHERE id = ?`,
args: [existing.rows[0].id],
});
dismissedCount++;
}
} else {
// Insert new dismissed record
await client.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, dismissed) VALUES (?, ?, 1)`,
args: [userId, doseId],
});
dismissedCount++;
}
}
return { success: true, dismissedCount };
async function insertDose(options: {
userId: number;
doseId: string;
markedBy?: string | null;
dismissed?: boolean;
takenAt?: number | null;
takenSource?: "manual" | "automatic";
}) {
await testClient.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by, dismissed, taken_at, taken_source)
VALUES (?, ?, ?, ?, ?, ?)`,
args: [
options.userId,
options.doseId,
options.markedBy ?? null,
options.dismissed ? 1 : 0,
options.takenAt === undefined ? Math.floor(Date.now() / 1000) : (options.takenAt ?? 0),
options.takenSource ?? "manual",
],
});
}
// =============================================================================
// Tests
// =============================================================================
describe("Dose Tracking API", () => {
let ctx: TestContext;
let app: FastifyInstance;
let userId: number;
let cookieHeader: string;
beforeAll(async () => {
ctx = await buildTestApp();
await registerDoseRoutes(ctx);
await ctx.app.ready();
await migrate(testDb, { migrationsFolder });
await runAlterMigrations(testClient);
app = Fastify({ logger: false });
await app.register(cookie, { secret: "test-cookie-secret" });
await app.register(jwt, {
secret: "test-jwt-secret",
cookie: { cookieName: "access_token", signed: false },
});
await app.register(doseRoutes);
await app.ready();
});
afterAll(async () => {
await closeTestApp(ctx);
await app.close();
testClient.close();
});
beforeEach(async () => {
await clearTestData(ctx.client);
// Create test user - will get ID 1 since table is cleared
userId = await createTestUser(ctx.client, { username: "testuser" });
// Reset SQLite autoincrement so user gets ID 1
await ctx.client.execute("DELETE FROM sqlite_sequence WHERE name='users'");
await clearTestData(ctx.client);
userId = await createTestUser(ctx.client, { username: "testuser" });
await clearTables();
userId = await createUser("dose-test-user");
cookieHeader = buildSessionCookie(app, userId, "dose-test-user");
});
// ---------------------------------------------------------------------------
// POST /doses/taken
// ---------------------------------------------------------------------------
describe("POST /doses/taken", () => {
it("should mark a dose as taken", async () => {
it("marks a dose as taken", async () => {
const doseId = "1-0-1735344000000";
const response = await ctx.app.inject({
const response = await app.inject({
method: "POST",
url: "/doses/taken",
headers: { cookie: cookieHeader },
payload: { doseId },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
// Verify in database
const result = await ctx.client.execute({
sql: `SELECT dose_id, marked_by FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
const result = await testClient.execute({
sql: "SELECT dose_id, marked_by, taken_source FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
args: [userId, doseId],
});
expect(result.rows.length).toBe(1);
expect(result.rows[0].dose_id).toBe(doseId);
expect(result.rows[0].marked_by).toBeNull();
expect(result.rows).toEqual([
expect.objectContaining({ dose_id: doseId, marked_by: null, taken_source: "manual" }),
]);
});
it("should return idempotent response when dose already marked", async () => {
it("returns an idempotent response when the dose is already marked", async () => {
const doseId = "1-0-1735344000000";
await insertDose({ userId, doseId });
// Mark once
await ctx.app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId },
});
// Mark again
const response = await ctx.app.inject({
const response = await app.inject({
method: "POST",
url: "/doses/taken",
headers: { cookie: cookieHeader },
payload: { doseId },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true, message: "Already marked" });
// Should still only have one record
const result = await ctx.client.execute({
sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
const countResult = await testClient.execute({
sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
args: [userId, doseId],
});
expect(result.rows[0].count).toBe(1);
expect(Number(countResult.rows[0].count)).toBe(1);
});
it("should reject request without doseId", async () => {
const response = await ctx.app.inject({
it("rejects requests without a doseId", async () => {
const response = await app.inject({
method: "POST",
url: "/doses/taken",
headers: { cookie: cookieHeader },
payload: {},
});
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "doseId is required" });
expect(response.json()).toEqual({ error: "Required" });
});
it("should reject request with empty doseId", async () => {
const response = await ctx.app.inject({
it("accepts dose IDs with a person suffix and special characters", async () => {
const doseId = "5-0-1735344000000-Max Müller";
const response = await app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId: "" },
headers: { cookie: cookieHeader },
payload: { doseId },
});
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "doseId is required" });
expect(response.statusCode).toBe(200);
const getResponse = await app.inject({
method: "GET",
url: "/doses/taken",
headers: { cookie: cookieHeader },
});
expect(getResponse.statusCode).toBe(200);
expect(getResponse.json().doses[0].doseId).toBe(doseId);
});
});
// ---------------------------------------------------------------------------
// GET /doses/taken
// ---------------------------------------------------------------------------
describe("GET /doses/taken", () => {
it("should return empty array when no doses taken", async () => {
const response = await ctx.app.inject({
it("returns an empty array when no doses were taken", async () => {
const response = await app.inject({
method: "GET",
url: "/doses/taken",
headers: { cookie: cookieHeader },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ doses: [] });
});
it("should return list of taken doses", async () => {
const doseId1 = "1-0-1735344000000";
const doseId2 = "1-0-1735430400000";
// Mark two doses
await ctx.app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId: doseId1 },
});
await ctx.app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId: doseId2 },
it("returns only the authenticated user's taken doses with metadata", async () => {
const otherUserId = await createUser("dose-other-user");
await insertDose({
userId,
doseId: "1-0-1735344000000",
markedBy: "Daniel",
takenSource: "automatic",
});
await insertDose({ userId, doseId: "1-0-1735430400000" });
await insertDose({ userId: otherUserId, doseId: "9-0-1735516800000" });
const response = await ctx.app.inject({
const response = await app.inject({
method: "GET",
url: "/doses/taken",
headers: { cookie: cookieHeader },
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.doses).toHaveLength(2);
expect(data.doses.map((d: { doseId: string }) => d.doseId).sort()).toEqual([doseId1, doseId2].sort());
// Each dose should have a takenAt timestamp
for (const dose of data.doses) {
expect(dose.takenAt).toBeTypeOf("number");
expect(dose.takenAt).toBeGreaterThan(0);
expect(dose.markedBy).toBeNull();
}
});
it("should include markedBy when present", async () => {
const doseId = "1-0-1735344000000";
// Insert directly with markedBy
await ctx.client.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, ?)`,
args: [userId, doseId, "Daniel"],
});
const response = await ctx.app.inject({
method: "GET",
url: "/doses/taken",
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.doses).toHaveLength(1);
expect(data.doses[0].markedBy).toBe("Daniel");
expect(data.doses.map((dose: { doseId: string }) => dose.doseId).sort()).toEqual([
"1-0-1735344000000",
"1-0-1735430400000",
]);
expect(data.doses).toEqual(
expect.arrayContaining([
expect.objectContaining({ markedBy: "Daniel", takenSource: "automatic" }),
expect.objectContaining({ markedBy: null, takenSource: "manual" }),
])
);
});
});
// ---------------------------------------------------------------------------
// DELETE /doses/taken/:doseId
// ---------------------------------------------------------------------------
describe("DELETE /doses/taken/:doseId", () => {
it("should unmark a dose", async () => {
it("unmarks an existing dose", async () => {
const doseId = "1-0-1735344000000";
await insertDose({ userId, doseId });
// Mark first
await ctx.app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId },
});
// Verify marked
let result = await ctx.client.execute({
sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE dose_id = ?`,
args: [doseId],
});
expect(result.rows[0].count).toBe(1);
// Unmark
const response = await ctx.app.inject({
const response = await app.inject({
method: "DELETE",
url: `/doses/taken/${encodeURIComponent(doseId)}`,
headers: { cookie: cookieHeader },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
// Verify unmarked
result = await ctx.client.execute({
sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE dose_id = ?`,
args: [doseId],
const countResult = await testClient.execute({
sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
args: [userId, doseId],
});
expect(result.rows[0].count).toBe(0);
expect(Number(countResult.rows[0].count)).toBe(0);
});
it("should succeed even if dose was not marked", async () => {
const doseId = "nonexistent-dose-id";
it("keeps the record when the dose is dismissed", async () => {
const doseId = "1-0-1735344000000";
await insertDose({ userId, doseId, dismissed: true });
const response = await ctx.app.inject({
const response = await app.inject({
method: "DELETE",
url: `/doses/taken/${encodeURIComponent(doseId)}`,
headers: { cookie: cookieHeader },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
const result = await testClient.execute({
sql: "SELECT dose_id, dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
args: [userId, doseId],
});
expect(result.rows).toEqual([expect.objectContaining({ dose_id: doseId, dismissed: 1 })]);
});
it("should preserve dismissed status when unmarking a dose", async () => {
const doseId = "1-0-1735344000000";
// First dismiss the dose
await ctx.app.inject({
method: "POST",
url: "/doses/dismiss",
payload: { doseIds: [doseId] },
});
// Verify it's dismissed
let result = await ctx.client.execute({
sql: `SELECT dismissed, taken_at FROM dose_tracking WHERE dose_id = ?`,
args: [doseId],
});
expect(result.rows[0].dismissed).toBe(1);
const originalTakenAt = result.rows[0].taken_at;
// Now try to unmark it (undo) - should keep the dismissed record
const response = await ctx.app.inject({
it("still succeeds when the dose does not exist", async () => {
const response = await app.inject({
method: "DELETE",
url: `/doses/taken/${encodeURIComponent(doseId)}`,
url: "/doses/taken/nonexistent-dose-id",
headers: { cookie: cookieHeader },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
// Verify the record still exists and is still dismissed
result = await ctx.client.execute({
sql: `SELECT dose_id, dismissed, taken_at FROM dose_tracking WHERE dose_id = ?`,
args: [doseId],
});
expect(result.rows.length).toBe(1);
expect(result.rows[0].dismissed).toBe(1);
expect(result.rows[0].taken_at).toBe(originalTakenAt); // unchanged
});
});
// ---------------------------------------------------------------------------
// Dose ID Format Tests
// ---------------------------------------------------------------------------
describe("Dose ID Format", () => {
it("should handle standard dose ID format: {medId}-{blisterIdx}-{timestamp}", async () => {
const doseId = "5-0-1735344000000";
const response = await ctx.app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
});
it("should handle dose ID with person: {medId}-{blisterIdx}-{timestamp}-{person}", async () => {
const doseId = "5-0-1735344000000-Daniel";
const response = await ctx.app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
});
it("should handle special characters in dose ID", async () => {
// Dose ID with URL-unsafe characters (edge case)
const doseId = "5-0-1735344000000-Max Müller";
const response = await ctx.app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId },
});
expect(response.statusCode).toBe(200);
// Can retrieve it
const getResponse = await ctx.app.inject({
method: "GET",
url: "/doses/taken",
});
expect(getResponse.json().doses[0].doseId).toBe(doseId);
});
});
// ---------------------------------------------------------------------------
// Dismiss Doses Tests (POST /doses/dismiss)
// ---------------------------------------------------------------------------
describe("POST /doses/dismiss", () => {
it("should dismiss multiple doses", async () => {
const doseIds = ["1-0-1735344000000", "1-0-1735430400000"];
const response = await ctx.app.inject({
it("dismisses multiple doses", async () => {
const response = await app.inject({
method: "POST",
url: "/doses/dismiss",
payload: { doseIds },
headers: { cookie: cookieHeader },
payload: { doseIds: ["1-0-1735344000000", "1-0-1735430400000"] },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true, dismissedCount: 2 });
// Verify in database
const result = await ctx.client.execute({
sql: `SELECT dose_id, dismissed FROM dose_tracking WHERE user_id = ? AND dismissed = 1`,
const result = await testClient.execute({
sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ? AND dismissed = 1",
args: [userId],
});
expect(result.rows.length).toBe(2);
expect(Number(result.rows[0].count)).toBe(2);
});
it("should not double-count already dismissed doses", async () => {
it("does not double-count already dismissed doses", async () => {
const doseId = "1-0-1735344000000";
await insertDose({ userId, doseId, dismissed: true });
// Dismiss once
await ctx.app.inject({
method: "POST",
url: "/doses/dismiss",
payload: { doseIds: [doseId] },
});
// Dismiss again
const response = await ctx.app.inject({
const response = await app.inject({
method: "POST",
url: "/doses/dismiss",
headers: { cookie: cookieHeader },
payload: { doseIds: [doseId] },
});
@@ -489,54 +335,71 @@ describe("Dose Tracking API", () => {
expect(response.json()).toEqual({ success: true, dismissedCount: 0 });
});
it("should reject empty doseIds array", async () => {
const response = await ctx.app.inject({
method: "POST",
url: "/doses/dismiss",
payload: { doseIds: [] },
});
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "doseIds array is required" });
});
it("should reject missing doseIds", async () => {
const response = await ctx.app.inject({
method: "POST",
url: "/doses/dismiss",
payload: {},
});
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "doseIds array is required" });
});
it("should dismiss a dose that was already taken (convert to dismissed)", async () => {
it("converts a taken dose into a dismissed one", async () => {
const doseId = "1-0-1735344000000";
await insertDose({ userId, doseId, dismissed: false });
// First mark as taken
await ctx.app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId },
});
// Then dismiss it
const response = await ctx.app.inject({
const response = await app.inject({
method: "POST",
url: "/doses/dismiss",
headers: { cookie: cookieHeader },
payload: { doseIds: [doseId] },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true, dismissedCount: 1 });
// Verify it's now dismissed
const result = await ctx.client.execute({
sql: `SELECT dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
const result = await testClient.execute({
sql: "SELECT dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
args: [userId, doseId],
});
expect(result.rows[0].dismissed).toBe(1);
expect(result.rows).toEqual([expect.objectContaining({ dismissed: 1 })]);
});
it("rejects missing or empty doseIds", async () => {
const emptyResponse = await app.inject({
method: "POST",
url: "/doses/dismiss",
headers: { cookie: cookieHeader },
payload: { doseIds: [] },
});
expect(emptyResponse.statusCode).toBe(400);
expect(emptyResponse.json()).toEqual({ error: "At least one doseId is required" });
const missingResponse = await app.inject({
method: "POST",
url: "/doses/dismiss",
headers: { cookie: cookieHeader },
payload: {},
});
expect(missingResponse.statusCode).toBe(400);
expect(missingResponse.json()).toEqual({ error: "Required" });
});
});
describe("DELETE /doses/dismiss", () => {
it("clears dismissed-only records and removes the dismissed flag from taken doses", async () => {
await insertDose({ userId, doseId: "1-0-1735344000000", dismissed: true, takenAt: null });
await insertDose({ userId, doseId: "1-0-1735430400000", dismissed: true, markedBy: "Daniel" });
const response = await app.inject({
method: "DELETE",
url: "/doses/dismiss",
headers: { cookie: cookieHeader },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true, clearedCount: 2 });
const rows = await testClient.execute({
sql: "SELECT dose_id, dismissed, marked_by FROM dose_tracking WHERE user_id = ? ORDER BY dose_id ASC",
args: [userId],
});
expect(rows.rows).toEqual([
expect.objectContaining({ dose_id: "1-0-1735430400000", dismissed: 0, marked_by: "Daniel" }),
]);
});
});
});
+253 -3
View File
@@ -345,6 +345,37 @@ describe("E2E Tests with Real Routes", () => {
usedPrescription: true,
});
});
it("should not include refill history entries from another user for the same medication", async () => {
const medId = await createMedication(testClient, userId, "Report Isolation Med", ["Daniel"]);
const otherUserId = await _createUser(testClient, "report-isolation-other-user");
await testClient.execute({
sql: `INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date)
VALUES (?, ?, ?, ?, ?, ?)`,
args: [medId, userId, 1, 0, 0, 1735603200],
});
await testClient.execute({
sql: `INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date)
VALUES (?, ?, ?, ?, ?, ?)`,
args: [medId, otherUserId, 9, 99, 1, 1735689600],
});
const response = await app.inject({
method: "POST",
url: "/medications/report-data",
payload: { medicationIds: [medId] },
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data[medId].refills).toHaveLength(1);
expect(data[medId].refills[0]).toMatchObject({
packsAdded: 1,
loosePillsAdded: 0,
usedPrescription: false,
});
});
});
afterAll(async () => {
@@ -503,6 +534,80 @@ describe("E2E Tests with Real Routes", () => {
expect(response.statusCode).toBe(404);
});
it("should return shared medication overview for a valid token", async () => {
await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
const token = "abcdef0123456789";
await createShareToken(testClient, userId, "Daniel", token);
const response = await app.inject({
method: "GET",
url: `/share/${token}/overview`,
});
expect(response.statusCode).toBe(200);
expect(response.headers["cache-control"]).toBe("no-store");
const data = response.json();
expect(data.takenBy).toBe("Daniel");
expect(data.sharedBy).toBe("__anonymous__");
expect(Array.isArray(data.medications)).toBe(true);
expect(data.medications).toHaveLength(1);
expect(data.medications[0].name).toBe("Aspirin");
expect(data.medications[0].currentStock).toBeTypeOf("number");
});
it("should return 404 for unknown overview token", async () => {
const response = await app.inject({
method: "GET",
url: "/share/abcdef0123456789/overview",
});
expect(response.statusCode).toBe(404);
expect(response.json()).toEqual({ error: "not_found" });
});
it("should return 410 for expired overview token", async () => {
const token = "fedcba9876543210";
await testClient.execute({
sql: "INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, expires_at) VALUES (?, ?, ?, 30, ?)",
args: [userId, token, "Daniel", Math.floor(Date.now() / 1000) - 60],
});
const response = await app.inject({
method: "GET",
url: `/share/${token}/overview`,
});
expect(response.statusCode).toBe(410);
const data = response.json();
expect(data.error).toBe("expired");
expect(data.expiredAt).toBeTypeOf("string");
});
it("should hide stock fields in overview when share_stock_status is disabled", async () => {
await createMedication(testClient, userId, "Ibuprofen", ["Daniel"]);
const token = "0123456789abcdef";
await createShareToken(testClient, userId, "Daniel", token);
await testClient.execute({
sql: "INSERT INTO user_settings (user_id, share_stock_status, low_stock_days) VALUES (?, 0, 30)",
args: [userId],
});
const response = await app.inject({
method: "GET",
url: `/share/${token}/overview`,
});
expect(response.statusCode).toBe(200);
const [medication] = response.json().medications;
expect(medication.currentStock).toBeNull();
expect(medication.capacity).toBeNull();
expect(medication.daysLeft).toBeNull();
expect(medication.depletionDate).toBeNull();
expect(medication.priority).toBeNull();
});
});
// ---------------------------------------------------------------------------
@@ -834,7 +939,7 @@ describe("E2E Tests with Real Routes", () => {
});
expect(response.statusCode).toBe(400);
expect(response.json().error).toBe("Invalid language");
expect(response.json().error).toMatch(/Invalid language|Bad Request/);
});
it("should create and update language via lightweight language endpoint", async () => {
@@ -1929,6 +2034,47 @@ describe("E2E Tests with Real Routes", () => {
expect(hasLooseRefill).toBe(true);
});
it("should not return refill history entries from another user for the same medication", async () => {
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Refill Isolation Med",
packCount: 1,
blistersPerPack: 2,
pillsPerBlister: 10,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
const medId = createResponse.json().id;
const otherUserId = await _createUser(testClient, "refill-isolation-other-user");
await testClient.execute({
sql: `INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date)
VALUES (?, ?, ?, ?, ?, ?)`,
args: [medId, userId, 2, 3, 0, 1735603200],
});
await testClient.execute({
sql: `INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date)
VALUES (?, ?, ?, ?, ?, ?)`,
args: [medId, otherUserId, 8, 88, 1, 1735689600],
});
const response = await app.inject({
method: "GET",
url: `/medications/${medId}/refills`,
});
expect(response.statusCode).toBe(200);
const refills = response.json();
expect(refills).toHaveLength(1);
expect(refills[0]).toMatchObject({
packsAdded: 2,
loosePillsAdded: 3,
usedPrescription: false,
});
});
it("should return 404 for non-existent medication", async () => {
const response = await app.inject({
method: "GET",
@@ -2302,6 +2448,29 @@ describe("E2E Tests with Real Routes", () => {
payload: {
emailEnabled: true,
notificationEmail: "test@example.com",
reminderDaysBefore: 7,
repeatDailyReminders: false,
lowStockDays: 30,
normalStockDays: 90,
highStockDays: 180,
shoutrrrEnabled: false,
shoutrrrUrl: "",
emailStockReminders: true,
emailIntakeReminders: true,
emailPrescriptionReminders: true,
shoutrrrStockReminders: true,
shoutrrrIntakeReminders: true,
shoutrrrPrescriptionReminders: true,
skipRemindersForTakenDoses: false,
repeatRemindersEnabled: false,
reminderRepeatIntervalMinutes: 30,
maxNaggingReminders: 5,
language: "en",
stockCalculationMode: "automatic",
shareStockStatus: true,
upcomingTodayOnly: false,
shareScheduleTodayOnly: false,
swapDashboardMainSections: false,
},
});
@@ -2506,10 +2675,10 @@ describe("E2E Tests with Real Routes", () => {
});
// ---------------------------------------------------------------------------
// Package Type (blister, bottle, liquid_container) Tests
// Package Type (blister, bottle, tube, liquid_container) Tests
// ---------------------------------------------------------------------------
describe("Package type handling (blister, bottle, liquid_container)", () => {
describe("Package type handling (blister, bottle, tube, liquid_container)", () => {
const bottleMedication = {
name: "Vitamin D Drops",
packageType: "bottle",
@@ -2542,6 +2711,21 @@ describe("E2E Tests with Real Routes", () => {
blisters: [{ usage: 5, every: 1, start: "2025-01-01T08:00:00.000Z" }],
};
const tubeMedication = {
name: "Topical Cream",
medicationForm: "topical",
packageType: "tube",
doseUnit: "units",
packCount: 2,
packageAmountValue: 40,
packageAmountUnit: "g",
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 80,
looseTablets: 80,
blisters: [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }],
};
it("should create and return bottle type medication", async () => {
const response = await app.inject({
method: "POST",
@@ -2698,6 +2882,72 @@ describe("E2E Tests with Real Routes", () => {
expect(data.refill.totalPillsAdded).toBe(35); // 1*30 + 5
});
it("should keep liquid_container refill additive and preserve amount baseline", async () => {
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload: {
...liquidContainerMedication,
packCount: 1,
packageAmountValue: 180,
totalPills: 180,
looseTablets: 180,
},
});
expect(createResponse.statusCode).toBe(200);
const medId = createResponse.json().id;
const refillResponse = await app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 1, loosePillsAdded: 0 },
});
expect(refillResponse.statusCode).toBe(200);
const refillData = refillResponse.json();
expect(refillData.refill.packsAdded).toBe(1);
expect(refillData.refill.loosePillsAdded).toBe(180);
expect(refillData.refill.totalPillsAdded).toBe(180);
expect(refillData.newStock.totalPills).toBe(360);
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
expect(medsResponse.statusCode).toBe(200);
const med = medsResponse.json().find((m: Record<string, unknown>) => m.id === medId);
expect(med).toBeTruthy();
expect(med.totalPills).toBe(360);
expect(med.looseTablets).toBe(360);
});
it("should keep tube refill additive and preserve amount baseline", async () => {
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload: tubeMedication,
});
expect(createResponse.statusCode).toBe(200);
const medId = createResponse.json().id;
const refillResponse = await app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 1, loosePillsAdded: 0 },
});
expect(refillResponse.statusCode).toBe(200);
const refillData = refillResponse.json();
expect(refillData.refill.packsAdded).toBe(1);
expect(refillData.refill.loosePillsAdded).toBe(40);
expect(refillData.refill.totalPillsAdded).toBe(40);
expect(refillData.newStock.totalPills).toBe(120);
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
expect(medsResponse.statusCode).toBe(200);
const med = medsResponse.json().find((m: Record<string, unknown>) => m.id === medId);
expect(med).toBeTruthy();
expect(med.totalPills).toBe(120);
expect(med.looseTablets).toBe(120);
});
it("should return correct totalPillsAdded in refill history for bottle type", async () => {
const createResponse = await app.inject({
method: "POST",
+297 -2
View File
@@ -45,7 +45,9 @@ vi.mock("nodemailer", () => ({
},
}));
const { settingsRoutes, sendShoutrrrNotification } = await import("../routes/settings.js");
const { settingsRoutes, sendShoutrrrNotification, loadUserSettings, getAllUserSettings } = await import(
"../routes/settings.js"
);
const { exportRoutes } = await import("../routes/export.js");
const { reportRoutes } = await import("../routes/report.js");
@@ -142,6 +144,73 @@ describe("Real route coverage: settings/export/report", () => {
expect(body.shareScheduleTodayOnly).toBe(false);
});
it("GET /settings returns a non-empty serialized payload with SMTP fields", async () => {
process.env.SMTP_HOST = "smtp.example.com";
process.env.SMTP_PORT = "2525";
process.env.SMTP_USER = "mailer@example.com";
process.env.SMTP_FROM = "MedAssist <mailer@example.com>";
process.env.SMTP_PASS = "secret";
await app.inject({
method: "PUT",
url: "/settings",
payload: {
emailEnabled: true,
notificationEmail: "person@example.com",
reminderDaysBefore: 5,
repeatDailyReminders: true,
lowStockDays: 14,
normalStockDays: 45,
highStockDays: 90,
shoutrrrEnabled: false,
shoutrrrUrl: "",
emailStockReminders: true,
emailIntakeReminders: true,
emailPrescriptionReminders: true,
shoutrrrStockReminders: true,
shoutrrrIntakeReminders: true,
shoutrrrPrescriptionReminders: true,
skipRemindersForTakenDoses: false,
repeatRemindersEnabled: true,
reminderRepeatIntervalMinutes: 20,
maxNaggingReminders: 4,
language: "en",
stockCalculationMode: "manual",
shareStockStatus: true,
upcomingTodayOnly: true,
shareScheduleTodayOnly: true,
swapDashboardMainSections: true,
},
});
const response = await app.inject({ method: "GET", url: "/settings" });
expect(response.statusCode).toBe(200);
expect(response.body).not.toBe("{}");
const body = response.json();
expect(body).toEqual(
expect.objectContaining({
emailEnabled: true,
notificationEmail: "person@example.com",
reminderDaysBefore: 5,
repeatDailyReminders: true,
repeatRemindersEnabled: true,
reminderRepeatIntervalMinutes: 20,
maxNaggingReminders: 4,
stockCalculationMode: "manual",
upcomingTodayOnly: true,
shareScheduleTodayOnly: true,
swapDashboardMainSections: true,
smtpHost: "smtp.example.com",
smtpPort: 2525,
smtpUser: "mailer@example.com",
smtpFrom: "MedAssist <mailer@example.com>",
hasSmtpPassword: true,
})
);
});
it("PUT /settings disables repeatDailyReminders when no stock reminder channel exists", async () => {
const response = await app.inject({
method: "PUT",
@@ -190,7 +259,30 @@ describe("Real route coverage: settings/export/report", () => {
payload: { language: "fr" },
});
expect(response.statusCode).toBe(400);
expect(response.json().error).toBe("Invalid language");
expect(response.json().error).toMatch(/Invalid language|Bad Request/);
});
it("PUT /settings/language creates and updates the stored language", async () => {
let response = await app.inject({
method: "PUT",
url: "/settings/language",
payload: { language: "de" },
});
expect(response.statusCode).toBe(200);
response = await app.inject({
method: "PUT",
url: "/settings/language",
payload: { language: "en" },
});
expect(response.statusCode).toBe(200);
const stored = await testClient.execute({
sql: "SELECT language FROM user_settings WHERE user_id = 1",
});
expect(stored.rows[0].language).toBe("en");
});
it("POST /settings/test-email fails when SMTP is not configured", async () => {
@@ -224,6 +316,22 @@ describe("Real route coverage: settings/export/report", () => {
expect(nodemailerSendMail).toHaveBeenCalledTimes(1);
});
it("POST /settings/test-email maps generic transport failures to HTTP 500", async () => {
process.env.SMTP_HOST = "smtp.example.com";
process.env.SMTP_USER = "mailer@example.com";
process.env.SMTP_PASS = "secret";
nodemailerSendMail.mockRejectedValue(new Error("socket hang up"));
const response = await app.inject({
method: "POST",
url: "/settings/test-email",
payload: { email: "person@example.com" },
});
expect(response.statusCode).toBe(500);
expect(response.json()).toMatchObject({ code: "TEST_EMAIL_FAILED" });
});
it("POST /settings/test-shoutrrr validates URL presence", async () => {
const response = await app.inject({
method: "POST",
@@ -233,6 +341,30 @@ describe("Real route coverage: settings/export/report", () => {
expect(response.statusCode).toBe(400);
});
it("POST /settings/test-shoutrrr returns 500 when notification delivery fails", async () => {
const response = await app.inject({
method: "POST",
url: "/settings/test-shoutrrr",
payload: { url: "ftp://invalid.example.com/topic" },
});
expect(response.statusCode).toBe(500);
expect(response.json().error).toMatch(/Only HTTP\/HTTPS protocols are allowed|Unsupported URL format/);
});
it("POST /settings/test-shoutrrr returns 200 for a valid ntfy target", async () => {
fetchMock.mockResolvedValue({ ok: true });
const response = await app.inject({
method: "POST",
url: "/settings/test-shoutrrr",
payload: { url: "ntfy://ntfy.sh/medassist" },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true, message: "Test notification sent successfully" });
});
it("sendShoutrrrNotification blocks localhost/private targets", async () => {
const result = await sendShoutrrrNotification("http://127.0.0.1/hook", "test", "message");
expect(result.success).toBe(false);
@@ -266,6 +398,169 @@ describe("Real route coverage: settings/export/report", () => {
expect(JSON.parse(call[1].body)).toMatchObject({ title: "Title", message: "Body" });
});
it("sendShoutrrrNotification returns HTTP response errors for ntfy-style endpoints", async () => {
fetchMock.mockResolvedValue({ ok: false, status: 429, text: () => Promise.resolve("rate limited") });
const result = await sendShoutrrrNotification("https://ntfy.sh/medassist", "Title", "Body");
expect(result).toEqual({ success: false, error: "HTTP 429: rate limited" });
});
it("sendShoutrrrNotification rejects invalid Discord webhook identifiers", async () => {
const result = await sendShoutrrrNotification("discord://bad-token@not-a-number", "Title", "Body");
expect(result).toEqual({ success: false, error: "Invalid Discord webhook ID" });
});
it("sendShoutrrrNotification validates Pushover URL credentials", async () => {
const result = await sendShoutrrrNotification("pushover://missing-token", "Title", "Body");
expect(result).toEqual({ success: false, error: "Invalid Pushover URL format" });
});
it("sendShoutrrrNotification requires Telegram chats and validates tokens", async () => {
let result = await sendShoutrrrNotification("telegram://123:abc@telegram", "Title", "Body");
expect(result).toEqual({ success: false, error: "Telegram URL requires chats parameter" });
result = await sendShoutrrrNotification("telegram://invalid@telegram?chats=123", "Title", "Body");
expect(result).toEqual({ success: false, error: "Invalid Telegram token format" });
});
it("sendShoutrrrNotification converts Gotify URLs and supports disabletls", async () => {
fetchMock.mockResolvedValue({ ok: true });
const result = await sendShoutrrrNotification(
"gotify://push.example.com/basepath/token123?disabletls=yes&priority=8",
"Title",
"Body"
);
expect(result).toEqual({ success: true });
const [targetUrl, requestInit] = fetchMock.mock.calls[0];
expect(targetUrl).toBe("http://push.example.com/basepath/message?token=token123");
expect(requestInit.body).toBe("Body\n\n(priority=8)");
expect(requestInit.headers).toMatchObject({ Tags: "pill" });
});
it("loadUserSettings creates defaults for users without settings", async () => {
const settings = await loadUserSettings(1);
expect(settings).toEqual(
expect.objectContaining({
userId: 1,
emailEnabled: false,
emailPrescriptionReminders: true,
shoutrrrPrescriptionReminders: true,
stockCalculationMode: "automatic",
shareStockStatus: true,
})
);
});
it("loadUserSettings maps persisted settings", async () => {
await testClient.execute({
sql: `INSERT INTO user_settings (
user_id, email_enabled, notification_email, email_stock_reminders, email_intake_reminders,
email_prescription_reminders, shoutrrr_enabled, shoutrrr_url, shoutrrr_stock_reminders,
shoutrrr_intake_reminders, shoutrrr_prescription_reminders, reminder_days_before,
repeat_daily_reminders, low_stock_days, normal_stock_days, high_stock_days, language,
stock_calculation_mode, share_stock_status, skip_reminders_for_taken_doses,
repeat_reminders_enabled, reminder_repeat_interval_minutes, max_nagging_reminders,
upcoming_today_only, share_schedule_today_only, swap_dashboard_main_sections
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
args: [
1,
1,
"person@example.com",
1,
1,
1,
0,
null,
1,
1,
1,
4,
0,
12,
30,
90,
"de",
"manual",
1,
0,
0,
30,
5,
0,
0,
0,
],
});
const settings = await loadUserSettings(1);
expect(settings).toEqual(
expect.objectContaining({
notificationEmail: "person@example.com",
skipRemindersForTakenDoses: false,
repeatRemindersEnabled: false,
reminderRepeatIntervalMinutes: 30,
maxNaggingReminders: 5,
stockCalculationMode: "manual",
shareStockStatus: true,
upcomingTodayOnly: false,
shareScheduleTodayOnly: false,
swapDashboardMainSections: false,
})
);
});
it("getAllUserSettings returns mapped entries for each persisted user", async () => {
await testClient.execute({
sql: "INSERT INTO users (id, username, auth_provider, is_active) VALUES (?, ?, ?, 1)",
args: [2, "second-user", "local"],
});
await testClient.execute({
sql: `INSERT INTO user_settings (
user_id, email_enabled, notification_email, email_stock_reminders, email_intake_reminders,
email_prescription_reminders, shoutrrr_enabled, shoutrrr_url, shoutrrr_stock_reminders,
shoutrrr_intake_reminders, shoutrrr_prescription_reminders, reminder_days_before,
repeat_daily_reminders, low_stock_days, normal_stock_days, high_stock_days, language,
stock_calculation_mode, share_stock_status, upcoming_today_only, share_schedule_today_only,
swap_dashboard_main_sections
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
args: [1, 0, null, 1, 1, 1, 1, "ntfy://ntfy.sh/topic", 1, 1, 1, 7, 1, 30, 60, 120, "en", "manual", 1, 1, 0, 1],
});
await testClient.execute({
sql: `INSERT INTO user_settings (
user_id, email_enabled, notification_email, email_stock_reminders, email_intake_reminders,
email_prescription_reminders, shoutrrr_enabled, shoutrrr_url, shoutrrr_stock_reminders,
shoutrrr_intake_reminders, shoutrrr_prescription_reminders, reminder_days_before,
repeat_daily_reminders, low_stock_days, normal_stock_days, high_stock_days, language,
stock_calculation_mode, share_stock_status, upcoming_today_only, share_schedule_today_only,
swap_dashboard_main_sections
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
args: [2, 1, "second@example.com", 0, 1, 1, 0, null, 1, 1, 1, 10, 0, 20, 50, 100, "de", "automatic", 1, 0, 0, 0],
});
const allSettings = await getAllUserSettings();
expect(allSettings).toHaveLength(2);
expect(allSettings).toEqual(
expect.arrayContaining([
expect.objectContaining({ userId: 1, stockCalculationMode: "manual", upcomingTodayOnly: true }),
expect.objectContaining({
userId: 2,
emailPrescriptionReminders: true,
shoutrrrPrescriptionReminders: true,
stockCalculationMode: "automatic",
shareStockStatus: true,
}),
])
);
});
it("POST /medications/report-data returns 403 for meds not owned by user", async () => {
await seedMedication("Owned Med");
const response = await app.inject({
@@ -0,0 +1,395 @@
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import cookie from "@fastify/cookie";
import jwt from "@fastify/jwt";
import sensible from "@fastify/sensible";
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";
const { testClient, testDb, mockedEnv, nodemailerSendMail } = vi.hoisted(() => {
const { createClient } = require("@libsql/client");
const { drizzle } = require("drizzle-orm/libsql");
const client = createClient({ url: ":memory:" });
const db = drizzle(client);
return {
testClient: client,
testDb: db,
mockedEnv: {
AUTH_ENABLED: true,
REGISTRATION_ENABLED: true,
FORM_LOGIN_ENABLED: true,
OIDC_ENABLED: false,
OIDC_PROVIDER_NAME: "SSO",
NODE_ENV: "test",
LOG_LEVEL: "silent",
PORT: 3000,
CORS_ORIGINS: "*",
JWT_SECRET: "test-jwt-secret",
REFRESH_SECRET: "test-refresh-secret",
COOKIE_SECRET: "test-cookie-secret",
ACCESS_TOKEN_TTL_MINUTES: 15,
REFRESH_TOKEN_TTL_DAYS: 7,
OPENAPI_DOCS_ENABLED: false,
},
nodemailerSendMail: vi.fn(),
};
});
vi.mock("../db/client.js", () => ({
db: testDb,
migrationsReady: Promise.resolve(),
}));
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
vi.mock("nodemailer", () => ({
default: {
createTransport: () => ({
sendMail: nodemailerSendMail,
}),
},
}));
const { settingsRoutes } = await import("../routes/settings.js");
const { apiKeyRoutes } = await import("../routes/api-keys.js");
const { hashApiKeyToken } = await import("../plugins/auth.js");
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const migrationsFolder = resolve(__dirname, "../../drizzle");
async function clearTables() {
await testClient.execute("DELETE FROM api_keys");
await testClient.execute("DELETE FROM refresh_tokens");
await testClient.execute("DELETE FROM user_settings");
await testClient.execute("DELETE FROM users");
}
async function createUser(username: string) {
const result = await testClient.execute({
sql: "INSERT INTO users (username, auth_provider, is_active) VALUES (?, 'local', 1) RETURNING id",
args: [username],
});
return Number(result.rows[0].id);
}
function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
const token = app.jwt.sign({ sub: userId, username });
return `access_token=${token}`;
}
async function insertApiKey(options: {
userId: number;
token: string;
scope?: "read" | "write";
isActive?: boolean;
expiresAt?: Date | null;
}) {
const expiresAtValue = options.expiresAt ? Math.floor(options.expiresAt.getTime() / 1000) : null;
const result = await testClient.execute({
sql: `INSERT INTO api_keys (user_id, name, key_hash, token_prefix, scope, is_active, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id`,
args: [
options.userId,
"Seeded Key",
hashApiKeyToken(options.token),
`${options.token.slice(0, 12)}...`,
options.scope ?? "write",
options.isActive === false ? 0 : 1,
expiresAtValue,
],
});
return Number(result.rows[0].id);
}
describe("Settings and API key security contracts", () => {
let app: FastifyInstance;
beforeAll(async () => {
await migrate(testDb, { migrationsFolder });
await runAlterMigrations(testClient);
app = Fastify({ logger: false });
await app.register(sensible);
await app.register(cookie, { secret: "test-cookie-secret" });
await app.register(jwt, {
secret: "test-jwt-secret",
cookie: { cookieName: "access_token", signed: false },
});
await app.register(settingsRoutes);
await app.register(apiKeyRoutes);
await app.ready();
});
afterAll(async () => {
await app.close();
testClient.close();
});
beforeEach(async () => {
vi.clearAllMocks();
await clearTables();
delete process.env.SMTP_HOST;
delete process.env.SMTP_USER;
delete process.env.SMTP_TOKEN;
delete process.env.SMTP_PASS;
delete process.env.SMTP_FROM;
delete process.env.SMTP_PORT;
delete process.env.SMTP_SECURE;
});
it("rejects GET /settings without authentication when auth is enabled", async () => {
const response = await app.inject({ method: "GET", url: "/settings" });
expect(response.statusCode).toBe(401);
expect(response.json()).toMatchObject({ code: "AUTH_REQUIRED" });
});
it("returns settings defaults for an authenticated session cookie", async () => {
const userId = await createUser("settings-session-user");
const response = await app.inject({
method: "GET",
url: "/settings",
headers: { cookie: buildSessionCookie(app, userId, "settings-session-user") },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual(
expect.objectContaining({
emailEnabled: false,
language: "en",
stockCalculationMode: "automatic",
})
);
});
it("allows GET /settings with a read-only API key", async () => {
const userId = await createUser("settings-read-user");
process.env.SMTP_HOST = "smtp.example.com";
process.env.SMTP_PORT = "2525";
const apiToken = "ma_read_only_valid_token_123456789";
await insertApiKey({ userId, token: apiToken, scope: "read" });
const response = await app.inject({
method: "GET",
url: "/settings",
headers: { authorization: `Bearer ${apiToken}` },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual(
expect.objectContaining({
smtpHost: "smtp.example.com",
smtpPort: 2525,
})
);
});
it("rejects PUT /settings with a read-only API key", async () => {
const userId = await createUser("settings-read-mutation-user");
const apiToken = "ma_read_only_mutation_token_123456789";
await insertApiKey({ userId, token: apiToken, scope: "read" });
const response = await app.inject({
method: "PUT",
url: "/settings",
headers: { authorization: `Bearer ${apiToken}` },
payload: {
emailEnabled: false,
notificationEmail: "",
reminderDaysBefore: 7,
repeatDailyReminders: false,
lowStockDays: 30,
normalStockDays: 90,
highStockDays: 180,
shoutrrrEnabled: false,
shoutrrrUrl: "",
emailStockReminders: true,
emailIntakeReminders: true,
emailPrescriptionReminders: true,
shoutrrrStockReminders: true,
shoutrrrIntakeReminders: true,
shoutrrrPrescriptionReminders: true,
skipRemindersForTakenDoses: false,
repeatRemindersEnabled: false,
reminderRepeatIntervalMinutes: 30,
maxNaggingReminders: 5,
language: "en",
stockCalculationMode: "automatic",
shareStockStatus: true,
upcomingTodayOnly: false,
shareScheduleTodayOnly: false,
swapDashboardMainSections: false,
},
});
expect(response.statusCode).toBe(403);
expect(response.json()).toMatchObject({ code: "API_KEY_SCOPE_FORBIDDEN" });
});
it("rejects invalid API key bearer tokens for GET /settings", async () => {
const response = await app.inject({
method: "GET",
url: "/settings",
headers: { authorization: "Bearer definitely-not-a-medassist-key" },
});
expect(response.statusCode).toBe(401);
expect(response.json()).toMatchObject({ code: "INVALID_API_KEY" });
});
it("rejects expired API keys for GET /settings", async () => {
const userId = await createUser("settings-expired-key-user");
const apiToken = "ma_expired_token_for_settings_123456789";
await insertApiKey({
userId,
token: apiToken,
scope: "read",
expiresAt: new Date(Date.now() - 60_000),
});
const response = await app.inject({
method: "GET",
url: "/settings",
headers: { authorization: `Bearer ${apiToken}` },
});
expect(response.statusCode).toBe(401);
expect(response.json()).toMatchObject({ code: "API_KEY_EXPIRED" });
});
it("rotates API keys and does not leak raw tokens from the list endpoint", async () => {
const userId = await createUser("api-key-session-user");
const cookieHeader = buildSessionCookie(app, userId, "api-key-session-user");
const firstCreate = await app.inject({
method: "POST",
url: "/auth/api-keys",
headers: { cookie: cookieHeader },
payload: { name: "Primary key", scope: "write", expiresInDays: 30 },
});
expect(firstCreate.statusCode).toBe(201);
const firstBody = firstCreate.json();
expect(firstBody.token).toMatch(/^ma_/);
const secondCreate = await app.inject({
method: "POST",
url: "/auth/api-keys",
headers: { cookie: cookieHeader },
payload: { name: "Rotated key", scope: "write", expiresInDays: 30 },
});
expect(secondCreate.statusCode).toBe(201);
const secondBody = secondCreate.json();
const listResponse = await app.inject({
method: "GET",
url: "/auth/api-keys",
headers: { cookie: cookieHeader },
});
expect(listResponse.statusCode).toBe(200);
expect(listResponse.body).not.toContain(firstBody.token);
expect(listResponse.body).not.toContain(secondBody.token);
expect(listResponse.body).not.toContain("keyHash");
expect(listResponse.json().keys).toHaveLength(2);
const dbState = await testClient.execute({
sql: "SELECT name, is_active FROM api_keys WHERE user_id = ? ORDER BY id ASC",
args: [userId],
});
expect(dbState.rows).toEqual([
expect.objectContaining({ name: "Primary key", is_active: 0 }),
expect.objectContaining({ name: "Rotated key", is_active: 1 }),
]);
});
it("rejects API key rotation when authenticated with a read-only API key", async () => {
const userId = await createUser("api-key-readonly-rotate-user");
const readOnlyToken = "ma_readonly_rotation_denied_123456789";
await insertApiKey({ userId, token: readOnlyToken, scope: "read" });
const response = await app.inject({
method: "POST",
url: "/auth/api-keys",
headers: { authorization: `Bearer ${readOnlyToken}` },
payload: { name: "Blocked rotation", scope: "write", expiresInDays: 30 },
});
expect(response.statusCode).toBe(403);
expect(response.json()).toMatchObject({ code: "API_KEY_SCOPE_FORBIDDEN" });
});
it("returns 404 when deleting an API key owned by a different user", async () => {
const ownerUserId = await createUser("api-key-owner");
const otherUserId = await createUser("api-key-other-user");
const otherCookieHeader = buildSessionCookie(app, otherUserId, "api-key-other-user");
const keyId = await insertApiKey({
userId: ownerUserId,
token: "ma_write_owner_token_123456789",
scope: "write",
});
const response = await app.inject({
method: "DELETE",
url: `/auth/api-keys/${keyId}`,
headers: { cookie: otherCookieHeader },
});
expect(response.statusCode).toBe(404);
expect(response.json()).toMatchObject({ code: "API_KEY_NOT_FOUND" });
});
it("maps SMTP recipient rejection to HTTP 400 instead of a generic 500", async () => {
const userId = await createUser("settings-email-recipient-user");
process.env.SMTP_HOST = "smtp.example.com";
process.env.SMTP_USER = "mailer@example.com";
process.env.SMTP_PASS = "secret";
nodemailerSendMail.mockResolvedValue({
accepted: [],
rejected: ["missing@example.com"],
response: "550 5.1.1 recipient address rejected",
});
const response = await app.inject({
method: "POST",
url: "/settings/test-email",
headers: { cookie: buildSessionCookie(app, userId, "settings-email-recipient-user") },
payload: { email: "missing@example.com" },
});
expect(response.statusCode).toBe(400);
expect(response.json()).toMatchObject({ code: "EMAIL_RECIPIENT_REJECTED" });
});
it("maps missing SMTP acceptance to HTTP 502 for test email", async () => {
const userId = await createUser("settings-email-unconfirmed-user");
process.env.SMTP_HOST = "smtp.example.com";
process.env.SMTP_USER = "mailer@example.com";
process.env.SMTP_PASS = "secret";
nodemailerSendMail.mockResolvedValue({
accepted: [],
rejected: [],
response: "250 queued without explicit acceptance",
});
const response = await app.inject({
method: "POST",
url: "/settings/test-email",
headers: { cookie: buildSessionCookie(app, userId, "settings-email-unconfirmed-user") },
payload: { email: "person@example.com" },
});
expect(response.statusCode).toBe(502);
expect(response.json()).toMatchObject({ code: "SMTP_DELIVERY_UNCONFIRMED" });
});
});
+7 -1
View File
@@ -5,7 +5,12 @@ import "@fastify/jwt";
export interface AuthUser {
id: number;
username: string;
role: string;
}
export interface AuthContext {
method: "session" | "api_key";
scope: "read" | "write";
apiKeyId?: number;
}
declare module "fastify" {
@@ -22,6 +27,7 @@ declare module "fastify" {
interface FastifyRequest {
user?: AuthUser | null;
authContext?: AuthContext;
correlationId?: string;
}
}