From 2a84a43654ef498e2ce4cc4b6a9007b4a784cd14 Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Sun, 8 Feb 2026 11:20:55 +0100 Subject: [PATCH] fix: unify data directory for dev and prod environments (#116) Add DATA_DIR env var support to configure the data directory path. All hardcoded resolve(cwd, 'data') paths now use a central getDataDir() function from db-utils.ts that checks DATA_DIR first, falling back to resolve(cwd, 'data'). This prevents local dev (cd backend && npm run dev) from creating a separate backend/data/ directory instead of using the root data/ folder. Changes: - Add getDataDir() to db-utils.ts as single source of truth - Update all 8 source files that reference the data directory - Add dotenv fallback to ../.env for local dev from backend/ - Add DATA_DIR documentation to .env.example - Add 7 new tests for getDataDir and getDbPaths with DATA_DIR - 493 tests pass, TypeScript clean --- .env.example | 5 ++ backend/src/db/client.ts | 9 ++- backend/src/db/db-utils.ts | 12 +++- backend/src/db/migrate.ts | 5 +- backend/src/index.ts | 3 +- backend/src/plugins/env.ts | 5 +- backend/src/routes/auth.ts | 7 ++- backend/src/routes/export.ts | 3 +- backend/src/routes/medications.ts | 3 +- .../src/services/intake-reminder-scheduler.ts | 3 +- backend/src/services/reminder-scheduler.ts | 3 +- backend/src/test/database.test.ts | 56 ++++++++++++++++++- backend/src/utils/server-config.ts | 4 +- 13 files changed, 102 insertions(+), 16 deletions(-) diff --git a/.env.example b/.env.example index 2c06b1f..48567da 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,11 @@ PORT=3000 CORS_ORIGINS=http://localhost:4174 LOG_LEVEL=info +# Data directory (where database, images, and state files are stored) +# Default: ./data relative to process.cwd() +# For local dev (cd backend && npm run dev), set to ../data so dev and prod share the same folder +# DATA_DIR=../data + # Timezone for scheduled reminders (e.g., Europe/Berlin, America/New_York) TZ=Europe/Berlin diff --git a/backend/src/db/client.ts b/backend/src/db/client.ts index 232ffdd..f62e599 100644 --- a/backend/src/db/client.ts +++ b/backend/src/db/client.ts @@ -1,4 +1,5 @@ -import { statSync } from "node:fs"; +import { existsSync, statSync } from "node:fs"; +import { resolve } from "node:path"; import { type Client, createClient } from "@libsql/client"; import dotenv from "dotenv"; import { drizzle } from "drizzle-orm/libsql"; @@ -7,6 +8,7 @@ import { drizzle } from "drizzle-orm/libsql"; import { ensureDataDirectory, ensureDefaultUser, + getDataDir, getDbPaths, repairOrphanedDoseIds, repairTrailingHyphenDoseIds, @@ -19,6 +21,7 @@ export { buildDbUrl, ensureDataDirectory, ensureDefaultUser, + getDataDir, getDbPaths, repairOrphanedDoseIds, repairTrailingHyphenDoseIds, @@ -26,7 +29,9 @@ export { runDrizzleMigrations, } from "./db-utils.js"; -dotenv.config({ path: process.env.DOTENV_PATH || ".env" }); +// Load .env: try cwd first, then parent dir (for local dev running from backend/) +const envPath = process.env.DOTENV_PATH || (existsSync(".env") ? ".env" : "../.env"); +dotenv.config({ path: envPath }); // ============================================================================= // Database initialization (runs on import) diff --git a/backend/src/db/db-utils.ts b/backend/src/db/db-utils.ts index a59a4de..c5a78ca 100644 --- a/backend/src/db/db-utils.ts +++ b/backend/src/db/db-utils.ts @@ -21,6 +21,16 @@ const migrationsFolder = resolve(__dirname, "../../drizzle"); // Path & Directory utilities // ============================================================================= +/** + * Get the data directory path. + * Checks DATA_DIR env var first, then falls back to resolve(cwd, "data"). + * This ensures local dev (`cd backend && npm run dev`) and Docker both + * use the same directory when DATA_DIR is set. + */ +export function getDataDir(cwd: string = process.cwd()): string { + return process.env.DATA_DIR ? resolve(process.env.DATA_DIR) : resolve(cwd, "data"); +} + /** Build the database URL from a path */ export function buildDbUrl(dbPath: string): string { return `file:${dbPath}`; @@ -28,7 +38,7 @@ export function buildDbUrl(dbPath: string): string { /** Get data directory and database path */ export function getDbPaths(cwd: string = process.cwd()): { dataDir: string; dbPath: string; url: string } { - const dataDir = resolve(cwd, "data"); + const dataDir = getDataDir(cwd); const dbPath = resolve(dataDir, "medassist-ng.db"); const url = buildDbUrl(dbPath); return { dataDir, dbPath, url }; diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index baad1b1..425f8e9 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -1,3 +1,4 @@ +import { existsSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { type Client, createClient } from "@libsql/client"; @@ -5,7 +6,9 @@ import dotenv from "dotenv"; import { drizzle } from "drizzle-orm/libsql"; import { migrate } from "drizzle-orm/libsql/migrator"; -dotenv.config({ path: process.env.DOTENV_PATH || ".env" }); +// Load .env: try cwd first, then parent dir (for local dev running from backend/) +const envPath = process.env.DOTENV_PATH || (existsSync(".env") ? ".env" : "../.env"); +dotenv.config({ path: envPath }); // Get migrations folder path (relative to this file's location) const __filename = fileURLToPath(import.meta.url); diff --git a/backend/src/index.ts b/backend/src/index.ts index a0b4abf..60a0242 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -10,6 +10,7 @@ import sensible from "@fastify/sensible"; import fastifyStatic from "@fastify/static"; 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 { authRoutes } from "./routes/auth.js"; import { doseRoutes } from "./routes/doses.js"; @@ -66,7 +67,7 @@ export async function createApp(options?: { accessTtlMinutes: options?.accessTtlMinutes ?? 15, refreshTtlDays: options?.refreshTtlDays ?? 7, isProduction: options?.isProduction ?? false, - imagesDir: options?.imagesDir ?? resolve(process.cwd(), "data/images"), + imagesDir: options?.imagesDir ?? resolve(getDataDir(), "images"), }; const app = Fastify({ diff --git a/backend/src/plugins/env.ts b/backend/src/plugins/env.ts index 99b2fa4..653f57e 100644 --- a/backend/src/plugins/env.ts +++ b/backend/src/plugins/env.ts @@ -1,7 +1,10 @@ +import { existsSync } from "node:fs"; import dotenv from "dotenv"; import { z } from "zod"; -dotenv.config({ path: process.env.DOTENV_PATH || ".env" }); +// Load .env: try cwd first, then parent dir (for local dev running from backend/) +const envPath = process.env.DOTENV_PATH || (existsSync(".env") ? ".env" : "../.env"); +dotenv.config({ path: envPath }); const EnvSchema = z.object({ NODE_ENV: z.enum(["development", "production", "test"]).default("production"), diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 859603c..7e77426 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -4,6 +4,7 @@ import { eq } from "drizzle-orm"; import type { FastifyInstance } from "fastify"; import { z } from "zod"; import { db } from "../db/client.js"; +import { getDataDir } from "../db/db-utils.js"; import { refreshTokens, users } from "../db/schema.js"; import { getAuthState, requireAuth } from "../plugins/auth.js"; import type { AuthUser } from "../types/fastify.js"; @@ -476,7 +477,7 @@ export async function authRoutes(app: FastifyInstance) { // Save file const fs = await import("node:fs/promises"); const path = await import("node:path"); - const imagesDir = path.join(process.cwd(), "data", "images"); + const imagesDir = path.join(getDataDir(), "images"); await fs.mkdir(imagesDir, { recursive: true }); const buffer = await data.toBuffer(); @@ -523,7 +524,7 @@ export async function authRoutes(app: FastifyInstance) { const fs = await import("node:fs/promises"); const path = await import("node:path"); try { - await fs.unlink(path.join(process.cwd(), "data", "images", user.avatarUrl)); + await fs.unlink(path.join(getDataDir(), "images", user.avatarUrl)); } catch { // Ignore if file doesn't exist } @@ -556,7 +557,7 @@ export async function authRoutes(app: FastifyInstance) { const fs = await import("node:fs/promises"); const path = await import("node:path"); try { - await fs.unlink(path.join(process.cwd(), "data", "images", user.avatarUrl)); + await fs.unlink(path.join(getDataDir(), "images", user.avatarUrl)); } catch { // Ignore if file doesn't exist } diff --git a/backend/src/routes/export.ts b/backend/src/routes/export.ts index d4bb296..9c4efee 100644 --- a/backend/src/routes/export.ts +++ b/backend/src/routes/export.ts @@ -5,13 +5,14 @@ import { eq } from "drizzle-orm"; import type { FastifyInstance } from "fastify"; import { z } from "zod"; import { db } from "../db/client.js"; +import { getDataDir } from "../db/db-utils.js"; import { doseTracking, medications, shareTokens, userSettings } 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 { parseIntakesJson, parseTakenByJson } from "../utils/scheduler-utils.js"; -const IMAGES_DIR = resolve(process.cwd(), "data/images"); +const IMAGES_DIR = resolve(getDataDir(), "images"); // ============================================================================= // Export Format Version (bump this when format changes) diff --git a/backend/src/routes/medications.ts b/backend/src/routes/medications.ts index b0aa8da..38e7010 100644 --- a/backend/src/routes/medications.ts +++ b/backend/src/routes/medications.ts @@ -5,13 +5,14 @@ import { and, eq, like } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import { db } from "../db/client.js"; +import { getDataDir } from "../db/db-utils.js"; import { doseTracking, medications } 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 { type Intake, parseIntakesJson, parseLocalDateTime, parseTakenByJson } from "../utils/scheduler-utils.js"; -const IMAGES_DIR = resolve(process.cwd(), "data/images"); +const IMAGES_DIR = resolve(getDataDir(), "images"); // New intake schema with per-intake takenBy const intakeSchema = z.object({ diff --git a/backend/src/services/intake-reminder-scheduler.ts b/backend/src/services/intake-reminder-scheduler.ts index ad14e25..cd602b5 100644 --- a/backend/src/services/intake-reminder-scheduler.ts +++ b/backend/src/services/intake-reminder-scheduler.ts @@ -3,6 +3,7 @@ import { resolve } from "node:path"; 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 { getDateLocale, getTranslations, type Language, t } from "../i18n/translations.js"; import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js"; @@ -25,7 +26,7 @@ import { updateReminderSentTime, updateUserReminderSentTime } from "./reminder-s const REMINDER_MINUTES_BEFORE = parseInt(process.env.REMINDER_MINUTES_BEFORE ?? "15", 10); const CHECK_INTERVAL_MS = 60 * 1000; // Check every 1 minute -const intakeReminderStateFile = resolve(process.cwd(), "data", "intake-reminder-state.json"); +const intakeReminderStateFile = resolve(getDataDir(), "intake-reminder-state.json"); function loadIntakeReminderState(): IntakeReminderState { try { diff --git a/backend/src/services/reminder-scheduler.ts b/backend/src/services/reminder-scheduler.ts index e81f7b2..0f1ee62 100644 --- a/backend/src/services/reminder-scheduler.ts +++ b/backend/src/services/reminder-scheduler.ts @@ -3,6 +3,7 @@ import { resolve } from "node:path"; import { eq } from "drizzle-orm"; import nodemailer from "nodemailer"; import { db } from "../db/client.js"; +import { getDataDir } from "../db/db-utils.js"; import { medications, userSettings } from "../db/schema.js"; import { getTranslations, type Language, t } from "../i18n/translations.js"; import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js"; @@ -25,7 +26,7 @@ import { const REMINDER_HOUR = parseInt(process.env.REMINDER_HOUR ?? "6", 10); // Default 6:00 AM local time -const reminderStateFile = resolve(process.cwd(), "data", "reminder-state.json"); +const reminderStateFile = resolve(getDataDir(), "reminder-state.json"); function loadReminderState(): ReminderState { try { diff --git a/backend/src/test/database.test.ts b/backend/src/test/database.test.ts index bb17850..9e1a7a9 100644 --- a/backend/src/test/database.test.ts +++ b/backend/src/test/database.test.ts @@ -5,13 +5,14 @@ import { fileURLToPath } from "node:url"; import { createClient } from "@libsql/client"; import { drizzle } from "drizzle-orm/libsql"; import { migrate } from "drizzle-orm/libsql/migrator"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // Import utility functions from db-utils (no side effects, unlike client.ts which initializes the DB) import { buildDbUrl, ensureDataDirectory, ensureDefaultUser, + getDataDir, getDbPaths, repairOrphanedDoseIds, repairTrailingHyphenDoseIds, @@ -144,8 +145,53 @@ describe("Database Client Utilities", () => { }); }); + describe("getDataDir", () => { + const originalDataDir = process.env.DATA_DIR; + + afterEach(() => { + if (originalDataDir === undefined) { + delete process.env.DATA_DIR; + } else { + process.env.DATA_DIR = originalDataDir; + } + }); + + it("should use DATA_DIR env var when set", () => { + process.env.DATA_DIR = "/custom/data"; + expect(getDataDir()).toBe("/custom/data"); + }); + + it("should resolve relative DATA_DIR to absolute", () => { + process.env.DATA_DIR = "../data"; + const result = getDataDir(); + expect(result).not.toContain(".."); + expect(result).toMatch(/\/data$/); + }); + + it("should fall back to cwd/data when DATA_DIR is not set", () => { + delete process.env.DATA_DIR; + expect(getDataDir("/app")).toBe("/app/data"); + }); + + it("should use DATA_DIR even when cwd is provided", () => { + process.env.DATA_DIR = "/override/data"; + expect(getDataDir("/app")).toBe("/override/data"); + }); + }); + describe("getDbPaths", () => { + const originalDataDir = process.env.DATA_DIR; + + afterEach(() => { + if (originalDataDir === undefined) { + delete process.env.DATA_DIR; + } else { + process.env.DATA_DIR = originalDataDir; + } + }); + it("should return correct paths based on cwd", () => { + delete process.env.DATA_DIR; const paths = getDbPaths("/app"); expect(paths.dataDir).toBe("/app/data"); expect(paths.dbPath).toBe("/app/data/medassist-ng.db"); @@ -153,10 +199,18 @@ describe("Database Client Utilities", () => { }); it("should use process.cwd() by default", () => { + delete process.env.DATA_DIR; const paths = getDbPaths(); expect(paths.dataDir).toContain("data"); expect(paths.dbPath).toContain("medassist-ng.db"); }); + + it("should respect DATA_DIR env var", () => { + process.env.DATA_DIR = "/custom/data"; + const paths = getDbPaths("/app"); + expect(paths.dataDir).toBe("/custom/data"); + expect(paths.dbPath).toBe("/custom/data/medassist-ng.db"); + }); }); describe("ensureDataDirectory", () => { diff --git a/backend/src/utils/server-config.ts b/backend/src/utils/server-config.ts index 56a2a95..ac0098b 100644 --- a/backend/src/utils/server-config.ts +++ b/backend/src/utils/server-config.ts @@ -6,6 +6,7 @@ import { existsSync, mkdirSync } from "node:fs"; import { resolve } from "node:path"; import type { CookieSerializeOptions } from "@fastify/cookie"; +import { getDataDir } from "../db/db-utils.js"; /** * Parse comma-separated CORS origins string @@ -81,8 +82,7 @@ export function buildAppConfig(options: AppConfigOptions): AppConfig { * Ensure images directory exists */ export function ensureImagesDirectory(cwd?: string): string { - const basePath = cwd || process.cwd(); - const imagesDir = resolve(basePath, "data/images"); + const imagesDir = resolve(getDataDir(cwd), "images"); if (!existsSync(imagesDir)) { mkdirSync(imagesDir, { recursive: true }); }