From ffab9ef4daecb7d93a152716739765fa0f905254 Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Fri, 16 Jan 2026 19:59:48 +0100 Subject: [PATCH] feat: Add data export/import functionality (#22) * feat: add data export/import functionality - Add /export and /import API endpoints with schema-independent JSON format - Export includes: medications, dose history, settings, share links - Uses _exportId references for medications, remapped on import - Images exported as base64 data URLs - Optional sensitive data inclusion (shoutrrr URLs, etc.) - Import replaces all existing data with confirmation warning - Add comprehensive test coverage - Add English and German translations - Add frontend UI in Settings page with export/import controls * fix: correct JSX structure and TypeScript types - Fix modal placement outside ternary expression in Settings - Add type assertion for request.body in import route test * docs: translate copilot-instructions to English - Add explicit rule that English is the primary language - Translate all German sections to English - User may communicate in German, but all project artifacts must be English --- .github/copilot-instructions.md | 249 ++++++---- backend/src/index.ts | 3 + backend/src/routes/export.ts | 499 +++++++++++++++++++ backend/src/test/export.test.ts | 851 ++++++++++++++++++++++++++++++++ backend/src/test/setup.ts | 13 +- frontend/src/App.tsx | 207 ++++++++ frontend/src/i18n/de.json | 22 + frontend/src/i18n/en.json | 22 + 8 files changed, 1778 insertions(+), 88 deletions(-) create mode 100644 backend/src/routes/export.ts create mode 100644 backend/src/test/export.test.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b220729..80caa6a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,6 +2,7 @@ ## General Rules +- **English is the primary language**: All code, comments, documentation, commit messages, PR descriptions, and GitHub releases MUST be written in English. The user may communicate in German, but all project artifacts must be in English. - **No temporary files**: Delete temporary scripts/files immediately after use. Do not commit temporary debug scripts, test files, or one-off utilities to the repository. - **Clean workspace**: Always clean up after yourself. If you create a file for a specific task, delete it once done. @@ -46,25 +47,25 @@ cd backend && npm run test:coverage # Run with coverage report ## Testing (MANDATORY) -> ⚠️ **WICHTIG**: Jede neue Funktionalität MUSS mit Tests abgedeckt werden! -> Pull Requests ohne Tests für neue Features werden nicht akzeptiert. +> ⚠️ **IMPORTANT**: Every new feature MUST be covered by tests! +> Pull Requests without tests for new features will not be accepted. -### Test-Framework -- **Vitest 2.1** mit v8 Coverage +### Test Framework +- **Vitest 2.1** with v8 Coverage - Tests in `backend/src/test/*.test.ts` -- Coverage-Ziel: Mindestens gleiche oder bessere Coverage nach Änderungen +- Coverage goal: At least equal or better coverage after changes -### Test-Struktur -| Datei | Testet | -|-------|--------| -| `routes.test.ts` | API-Endpunkte (Auth, Medications, Doses, Settings, Share, Planner) | -| `services.test.ts` | Scheduler-Utilities (Timezone, Blisters, Usage-Berechnung) | -| `db.test.ts` | Datenbank-Schema und Operationen | +### Test Structure +| File | Tests | +|------|-------| +| `routes.test.ts` | API endpoints (Auth, Medications, Doses, Settings, Share, Planner) | +| `services.test.ts` | Scheduler utilities (Timezone, Blisters, Usage calculation) | +| `db.test.ts` | Database schema and operations | -### Tests schreiben +### Writing Tests ```typescript -// Backend Test Beispiel (backend/src/test/example.test.ts) +// Backend Test Example (backend/src/test/example.test.ts) import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { createTestApp, createTestUser } from './routes.test'; // Test-Utilities @@ -95,24 +96,24 @@ describe('Feature Name', () => { }); ``` -### Test-Commands +### Test Commands ```bash cd backend -CI=true npm test # Tests einmal ausführen (IMMER so ausführen!) -CI=true npm run test:coverage # Mit Coverage-Report -npm test -- --watch # Watch-Mode für manuelle Entwicklung -npm test -- -t "test name" # Einzelnen Test ausführen +CI=true npm test # Run tests once (ALWAYS run this way!) +CI=true npm run test:coverage # With coverage report +npm test -- --watch # Watch mode for manual development +npm test -- -t "test name" # Run single test ``` -> ⚠️ **WICHTIG für AI-Agenten**: Tests IMMER mit `CI=true` ausführen! -> Ohne `CI=true` läuft Vitest im Watch-Mode und wartet auf Eingaben. +> ⚠️ **IMPORTANT for AI agents**: ALWAYS run tests with `CI=true`! +> Without `CI=true`, Vitest runs in watch mode and waits for input. ## CI/CD Pipeline (GitHub Actions) -### Workflow-Übersicht +### Workflow Overview ``` -Pull Request erstellt +Pull Request created ↓ ┌─────────────────────────────────────┐ │ test.yml │ @@ -124,65 +125,141 @@ Pull Request erstellt │ ├─ npm ci │ │ └─ npm run build │ └─────────────────────────────────────┘ - ↓ Tests müssen bestehen - PR kann gemerged werden + ↓ Tests must pass + PR can be merged ↓ -Push to main / Tag erstellt +Push to main / Tag created ↓ ┌─────────────────────────────────────┐ │ docker-build.yml │ │ ├─ backend-test (parallel) │ │ ├─ frontend-build (parallel) │ -│ └─ build-and-push (nach Tests) │ -│ ├─ Docker Images bauen │ -│ └─ Push zu GHCR │ +│ └─ build-and-push (after tests) │ +│ ├─ Build Docker images │ +│ └─ Push to GHCR │ └─────────────────────────────────────┘ ``` ### Branch Protection -> ⚠️ **WICHTIG**: Der `main` Branch ist geschützt! -> Direktes Pushen nach `main` ist **nicht möglich** - GitHub lehnt den Push ab. -> Alle Änderungen müssen über Pull Requests erfolgen. +> ⚠️ **IMPORTANT**: The `main` branch is protected! +> Direct pushing to `main` is **not possible** - GitHub will reject the push. +> All changes must go through Pull Requests. -- **main** Branch ist geschützt (Repository Rules) -- Direktes Pushen wird von GitHub abgelehnt mit: `GH013: Repository rule violations` -- PRs benötigen: - - ✅ `backend-test` Status Check bestanden - - ✅ `frontend-build` Status Check bestanden -- Nach erfolgreichem Merge wird der Feature-Branch automatisch gelöscht +- **main** branch is protected (Repository Rules) +- Direct pushing is rejected by GitHub with: `GH013: Repository rule violations` +- PRs require: + - ✅ `backend-test` Status Check passed + - ✅ `frontend-build` Status Check passed +- After successful merge, the feature branch is automatically deleted -**Workflow für Änderungen:** +**Workflow for changes:** ```bash -# 1. Feature Branch erstellen -git checkout -b feat/mein-feature +# 1. Create feature branch +git checkout -b feat/my-feature -# 2. Änderungen committen und pushen -git add . && git commit -m "feat: Beschreibung" -git push -u origin feat/mein-feature +# 2. Commit and push changes +git add . && git commit -m "feat: Description" +git push -u origin feat/my-feature -# 3. PR erstellen (via GitHub CLI oder Web) -gh pr create --title "Mein Feature" --body "Beschreibung" +# 3. Create PR (via GitHub CLI or Web) +gh pr create --title "My Feature" --body "Description" -# 4. Warten bis CI grün ist, dann mergen +# 4. Wait until CI is green, then merge gh pr merge --squash --delete-branch ``` -### Workflow-Dateien -| Datei | Trigger | Zweck | -|-------|---------|-------| -| `.github/workflows/test.yml` | Pull Requests | Tests ausführen, PR blockieren bei Fehlern | -| `.github/workflows/docker-build.yml` | Push to main, Tags | Tests + Docker Images bauen und pushen | +### Workflow Files +| File | Trigger | Purpose | +|------|---------|--------| +| `.github/workflows/test.yml` | Pull Requests | Run tests, block PR on failures | +| `.github/workflows/docker-build.yml` | Push to main, Tags | Tests + Build and push Docker images | -### Neuen Code hinzufügen - Checkliste -1. ✅ Feature implementieren -2. ✅ Tests für das Feature schreiben -3. ✅ Lokal `npm run test:coverage` ausführen -4. ✅ Coverage darf nicht sinken -5. ✅ Feature Branch erstellen und pushen -6. ✅ Pull Request erstellen -7. ✅ Warten bis CI grün ist -8. ✅ PR mergen (Branch wird automatisch gelöscht) +### Adding New Code - Checklist +1. ✅ Implement feature +2. ✅ Write tests for the feature +3. ✅ Run `npm run test:coverage` locally +4. ✅ Coverage must not decrease +5. ✅ Create and push feature branch +6. ✅ Create Pull Request +7. ✅ Wait until CI is green +8. ✅ Merge PR (branch is automatically deleted) + +## GitHub Releases + +> ⚠️ **IMPORTANT**: All GitHub Releases must be written in **English**! + +### Creating Release Notes + +> ⚠️ **MANDATORY**: GitHub Releases MUST contain a written message! +> Not just auto-generated commit lists, but a brief descriptive text. + +**Structure of a release text:** + +1. **Intro** (1-2 sentences): What's new, what was improved? +2. **Features & Changes**: Brief list of key changes +3. **Breaking Changes Warning** (if applicable): See below +4. **Optional**: Acknowledgements, documentation links + +**Example of good release notes:** + +```markdown +## What's New + +This release adds intake reminder notifications and improves medication stock tracking. Users can now configure nagging reminders for missed doses and receive alerts when medication stock runs low. + +### New Features +- 🔔 Intake reminder notifications with configurable nagging intervals +- 📊 Enhanced stock calculation with blister tracking +- 🌐 German translation improvements + +### Bug Fixes +- Fixed timezone handling in dose scheduling +- Improved image upload validation + +### Full Changelog +[All commits since v1.2.0](link) +``` + +### Breaking Changes Warning (CRITICAL!) + +> ⚠️ **MANDATORY**: If an update breaks existing configurations or stored data, it MUST be prominently warned about in the release notes! + +**Breaking Changes include:** +- Database schema changes without automatic migration +- Removed or renamed ENV variables +- Changed API endpoints +- Incompatible `.env` format changes +- Loss of stored data after update + +**Format for Breaking Changes:** + +```markdown +## ⚠️ BREAKING CHANGES - Please read before updating! + +**Database migration required**: This update changes the database schema. +Existing installations need to: +1. Create backup of `data/` folder +2. Stop containers +3. Perform update +4. If issues occur: Rollback using backup + +**ENV variables changed**: +- `OLD_VAR` was renamed to `NEW_VAR` +- `REMOVED_VAR` is no longer supported + +**Medication data**: Intake schedules with only one time entry will be automatically +migrated. Please verify all times are correct after update. +``` + +**What is NOT a Breaking Change:** +- ✅ New optional columns with DEFAULT values +- ✅ New ENV variables (with sensible defaults) +- ✅ New features that don't affect existing data +- ✅ Bug fixes that correct behavior + +**Rule of thumb**: If a user can simply run `docker compose pull && docker compose up -d` +without adjusting anything → Not a Breaking Change. ## Key Patterns @@ -371,54 +448,54 @@ Example: `5-0-1735344000000` = Medication 5, Blister 0, timestamp - **Environment**: Copy `.env.example` → `.env`, secrets must be 10+ chars - **i18n**: All UI text via `t('key')` function, translations in `frontend/src/i18n/*.json` -## Database Schema Changes (WICHTIG: Abwärtskompatibilität!) +## Database Schema Changes (IMPORTANT: Backward Compatibility!) -> ⚠️ **KRITISCH**: Die App MUSS abwärtskompatibel mit älteren Datenbanken bleiben! -> Nutzer upgraden ihre Docker-Container, aber behalten ihre bestehende DB. -> Die App darf NICHT abstürzen wenn alte Spalten fehlen. +> ⚠️ **CRITICAL**: The app MUST remain backward compatible with older databases! +> Users upgrade their Docker containers but keep their existing DB. +> The app must NOT crash if old columns are missing. -### Regeln für neue Spalten +### Rules for New Columns -1. **IMMER mit DEFAULT-Wert**: Neue Spalten müssen `NOT NULL DEFAULT ` haben -2. **NULL-sicher im Code**: Alle Abfragen müssen `?? defaultValue` oder `?? false` verwenden -3. **Schema-SQL aktualisieren**: In diesen Dateien hinzufügen: +1. **ALWAYS with DEFAULT value**: New columns must have `NOT NULL DEFAULT ` +2. **NULL-safe in code**: All queries must use `?? defaultValue` or `?? false` +3. **Update schema SQL**: Add to these files: - `backend/src/db/schema.ts` - Drizzle Schema - - `backend/src/db/schema-sql.ts` - `getTableCreationSQL()` für neue DBs - - `backend/src/db/client.ts` - `ALTER TABLE ADD COLUMN IF NOT EXISTS` Migration -4. **Test-Schemas updaten**: Alle Test-Dateien mit eigenem Schema: + - `backend/src/db/schema-sql.ts` - `getTableCreationSQL()` for new DBs + - `backend/src/db/client.ts` - `ALTER TABLE ADD COLUMN IF NOT EXISTS` migration +4. **Update test schemas**: All test files with their own schema: - `backend/src/test/e2e-routes.test.ts` - `backend/src/test/integration.test.ts` - `backend/src/test/planner.test.ts` -### Beispiel: Neue Spalte hinzufügen +### Example: Adding a New Column ```typescript -// 1. schema.ts - Drizzle Definition +// 1. schema.ts - Drizzle definition maxNaggingReminders: integer("max_nagging_reminders").notNull().default(5), -// 2. schema-sql.ts - Für neue Datenbanken +// 2. schema-sql.ts - For new databases "max_nagging_reminders integer NOT NULL DEFAULT 5," -// 3. client.ts - Migration für bestehende DBs (IN ensureTablesExist()) +// 3. client.ts - Migration for existing DBs (IN ensureTablesExist()) await client.execute(`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`).catch(() => {}); -// 4. Routes - NULL-sicher lesen +// 4. Routes - NULL-safe reading maxNaggingReminders: settings.maxNaggingReminders ?? 5, ``` -### Was NICHT erlaubt ist +### What is NOT Allowed -- ❌ Spalten löschen oder umbenennen (bricht alte DBs) -- ❌ `NOT NULL` ohne `DEFAULT` (INSERT schlägt fehl) -- ❌ Spalten ohne Fallback im Code lesen -- ❌ DB löschen als "Lösung" dokumentieren +- ❌ Deleting or renaming columns (breaks old DBs) +- ❌ `NOT NULL` without `DEFAULT` (INSERT fails) +- ❌ Reading columns without fallback in code +- ❌ Documenting "delete DB" as a solution -### Wann Abwärtskompatibilität NICHT möglich ist +### When Backward Compatibility is NOT Possible -Wenn eine Breaking Change unvermeidbar ist: -1. **Explizit kommunizieren**: In Release Notes dokumentieren -2. **Migration-Script**: Automatisches Upgrade-Script bereitstellen -3. **Versionsprüfung**: App sollte DB-Version prüfen und warnen +If a breaking change is unavoidable: +1. **Explicitly communicate**: Document in release notes +2. **Migration script**: Provide automatic upgrade script +3. **Version check**: App should check DB version and warn ## File Locations diff --git a/backend/src/index.ts b/backend/src/index.ts index 138017e..59732cc 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -19,6 +19,7 @@ import { settingsRoutes } from "./routes/settings.js"; import { plannerRoutes } from "./routes/planner.js"; import { shareRoutes } from "./routes/share.js"; import { doseRoutes } from "./routes/doses.js"; +import { exportRoutes } from "./routes/export.js"; import { startReminderScheduler } from "./services/reminder-scheduler.js"; import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js"; @@ -113,6 +114,7 @@ export async function createApp(options?: { await app.register(plannerRoutes); await app.register(shareRoutes); await app.register(doseRoutes); + await app.register(exportRoutes); return app; } @@ -181,6 +183,7 @@ await app.register(settingsRoutes); await app.register(plannerRoutes); await app.register(shareRoutes); await app.register(doseRoutes); +await app.register(exportRoutes); const start = async () => { try { diff --git a/backend/src/routes/export.ts b/backend/src/routes/export.ts new file mode 100644 index 0000000..6b6704b --- /dev/null +++ b/backend/src/routes/export.ts @@ -0,0 +1,499 @@ +import { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { randomBytes } from "crypto"; +import { db } from "../db/client.js"; +import { medications, userSettings, doseTracking, shareTokens } from "../db/schema.js"; +import { eq } from "drizzle-orm"; +import { requireAuth, getAnonymousUserId } from "../plugins/auth.js"; +import { env } from "../plugins/env.js"; +import type { AuthUser } from "../types/fastify.js"; +import { resolve, extname } from "path"; +import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from "fs"; + +const IMAGES_DIR = resolve(process.cwd(), "data/images"); + +// ============================================================================= +// Export Format Version (bump this when format changes) +// ============================================================================= +const EXPORT_VERSION = "1.0"; + +// ============================================================================= +// Zod Schemas for Import Validation +// ============================================================================= + +const scheduleSchema = z.object({ + usage: z.number().nonnegative(), + every: z.number().int().min(1), + start: z.string(), // ISO datetime string + remind: z.boolean().optional().default(false), +}); + +const inventorySchema = z.object({ + packCount: z.number().int().min(0).default(1), + blistersPerPack: z.number().int().min(1).default(1), + pillsPerBlister: z.number().int().min(1).default(1), + looseTablets: z.number().int().min(0).default(0), +}); + +const medicationExportSchema = z.object({ + _exportId: z.string(), + name: z.string().min(1), + genericName: z.string().nullable().optional(), + takenBy: z.array(z.string()).default([]), + inventory: inventorySchema, + pillWeightMg: z.number().int().nullable().optional(), + schedules: z.array(scheduleSchema).default([]), + expiryDate: z.string().nullable().optional(), + notes: z.string().nullable().optional(), + intakeRemindersEnabled: z.boolean().default(false), + image: z.string().nullable().optional(), // base64 data URL or null +}); + +const doseHistorySchema = z.object({ + medicationRef: z.string(), // References _exportId + scheduleIndex: z.number().int().min(0), + scheduledTime: z.string(), // ISO datetime + takenAt: z.string(), // ISO datetime + markedBy: z.string().nullable().optional(), +}); + +const shareLinkSchema = z.object({ + takenBy: z.string().min(1), + scheduleDays: z.number().int().min(1).default(30), + expiresAt: z.string().nullable().optional(), // ISO datetime + regenerateToken: z.boolean().default(true), +}); + +const settingsExportSchema = z.object({ + // Email notifications + emailEnabled: z.boolean().default(false), + notificationEmail: z.string().nullable().optional(), + emailStockReminders: z.boolean().default(true), + emailIntakeReminders: z.boolean().default(true), + // Push notifications + shoutrrrEnabled: z.boolean().optional(), + shoutrrrUrl: z.string().nullable().optional(), + shoutrrrStockReminders: z.boolean().default(true), + shoutrrrIntakeReminders: z.boolean().default(true), + // Reminder settings + reminderDaysBefore: z.number().int().default(7), + repeatDailyReminders: z.boolean().default(false), + skipRemindersForTakenDoses: z.boolean().default(false), + repeatRemindersEnabled: z.boolean().default(false), + reminderRepeatIntervalMinutes: z.number().int().default(30), + maxNaggingReminders: z.number().int().default(5), + // Stock thresholds + lowStockDays: z.number().int().default(30), + normalStockDays: z.number().int().default(90), + highStockDays: z.number().int().default(180), + // UI preferences + language: z.string().default("en"), + stockCalculationMode: z.enum(["automatic", "manual"]).default("automatic"), +}).optional(); + +const importDataSchema = z.object({ + version: z.string(), + exportedAt: z.string(), + includeSensitiveData: z.boolean().default(false), + medications: z.array(medicationExportSchema).default([]), + doseHistory: z.array(doseHistorySchema).default([]), + settings: settingsExportSchema, + shareLinks: z.array(shareLinkSchema).default([]), +}); + +// ============================================================================= +// Helper Functions +// ============================================================================= + +// Helper to get user ID from request +async function getUserId(request: any, reply: any): Promise { + if (!env.AUTH_ENABLED) { + return getAnonymousUserId(); + } + + const authUser = request.user as unknown as AuthUser | null; + if (!authUser) { + reply.status(401).send({ error: "Not authenticated" }); + throw new Error("AUTH_REQUIRED"); + } + return authUser.id; +} + +// Parse takenByJson safely +function parseTakenByJson(takenByJson: string | null | undefined): string[] { + if (!takenByJson) return []; + try { + const parsed = JSON.parse(takenByJson); + return Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string" && s.trim()) : []; + } catch { + return []; + } +} + +// Parse blisters from DB format to export format +function parseBlistersForExport(row: typeof medications.$inferSelect): Array<{ usage: number; every: number; start: string; remind: boolean }> { + try { + const usage = JSON.parse(row.usageJson || "[]") as number[]; + const every = JSON.parse(row.everyJson || "[]") as number[]; + const start = JSON.parse(row.startJson || "[]") as string[]; + const len = Math.min(usage.length, every.length, start.length); + const schedules: Array<{ usage: number; every: number; start: string; remind: boolean }> = []; + for (let i = 0; i < len; i++) { + schedules.push({ + usage: usage[i], + every: every[i], + start: start[i], + remind: row.intakeRemindersEnabled ?? false, + }); + } + return schedules; + } catch { + return []; + } +} + +// Read image file and convert to base64 data URL +function imageToBase64(imageUrl: string | null): string | null { + if (!imageUrl) return null; + const imagePath = resolve(IMAGES_DIR, imageUrl); + if (!existsSync(imagePath)) return null; + + try { + const imageBuffer = readFileSync(imagePath); + const ext = extname(imageUrl).toLowerCase(); + const mimeTypes: Record = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".webp": "image/webp", + ".gif": "image/gif", + }; + const mimeType = mimeTypes[ext] || "image/jpeg"; + return `data:${mimeType};base64,${imageBuffer.toString("base64")}`; + } catch { + return null; + } +} + +// Save base64 image to file and return filename +function base64ToImage(base64: string, medicationId: number): string | null { + if (!base64 || !base64.startsWith("data:")) return null; + + try { + // Parse data URL: "data:image/jpeg;base64,/9j/4AAQ..." + const matches = base64.match(/^data:image\/(\w+);base64,(.+)$/); + if (!matches) return null; + + const ext = matches[1] === "jpeg" ? "jpg" : matches[1]; + const data = matches[2]; + const buffer = Buffer.from(data, "base64"); + + const filename = `med-${medicationId}-${Date.now()}.${ext}`; + const filepath = resolve(IMAGES_DIR, filename); + + // Ensure images directory exists + if (!existsSync(IMAGES_DIR)) { + mkdirSync(IMAGES_DIR, { recursive: true }); + } + + writeFileSync(filepath, buffer); + return filename; + } catch { + return null; + } +} + +// Parse dose ID to extract medication ID and timestamp +// Format: "{medicationId}-{blisterIndex}-{timestampMs}" +function parseDoseId(doseId: string): { medicationId: number; blisterIndex: number; timestampMs: number } | null { + const parts = doseId.split("-"); + if (parts.length < 3) return null; + + const medicationId = parseInt(parts[0], 10); + const blisterIndex = parseInt(parts[1], 10); + const timestampMs = parseInt(parts[2], 10); + + if (isNaN(medicationId) || isNaN(blisterIndex) || isNaN(timestampMs)) return null; + + return { medicationId, blisterIndex, timestampMs }; +} + +// Build dose ID from parts +function buildDoseId(medicationId: number, blisterIndex: number, timestampMs: number): string { + return `${medicationId}-${blisterIndex}-${timestampMs}`; +} + +// ============================================================================= +// Export Routes +// ============================================================================= +export async function exportRoutes(app: FastifyInstance) { + // All export routes require auth + app.addHook("preHandler", requireAuth); + + // --------------------------------------------------------------------------- + // GET /export - Export all user data + // --------------------------------------------------------------------------- + app.get<{ Querystring: { includeSensitive?: string } }>( + "/export", + async (request, reply) => { + const userId = await getUserId(request, reply); + const includeSensitive = request.query.includeSensitive === "true"; + + // 1. Load all medications + const meds = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id); + + // Build medication ID to export ID mapping + const medIdToExportId = new Map(); + const exportMedications = meds.map((med, index) => { + const exportId = `med-${index + 1}`; + medIdToExportId.set(med.id, exportId); + + return { + _exportId: exportId, + name: med.name, + genericName: med.genericName, + takenBy: parseTakenByJson(med.takenByJson), + inventory: { + packCount: med.packCount ?? 1, + blistersPerPack: med.blistersPerPack ?? 1, + pillsPerBlister: med.pillsPerBlister ?? 1, + looseTablets: med.looseTablets ?? 0, + }, + pillWeightMg: med.pillWeightMg, + schedules: parseBlistersForExport(med), + expiryDate: med.expiryDate, + notes: med.notes, + intakeRemindersEnabled: med.intakeRemindersEnabled ?? false, + image: imageToBase64(med.imageUrl), + }; + }); + + // 2. Load all dose tracking entries + const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId)); + + const exportDoseHistory = doses.map((dose) => { + const parsed = parseDoseId(dose.doseId); + if (!parsed) return null; + + const exportId = medIdToExportId.get(parsed.medicationId); + if (!exportId) return null; // Orphaned dose, skip + + return { + medicationRef: exportId, + scheduleIndex: parsed.blisterIndex, + scheduledTime: new Date(parsed.timestampMs).toISOString(), + takenAt: dose.takenAt?.toISOString() ?? new Date().toISOString(), + markedBy: dose.markedBy, + }; + }).filter((d): d is NonNullable => d !== null); + + // 3. Load user settings + const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId)); + + const exportSettings = settings ? { + emailEnabled: settings.emailEnabled, + notificationEmail: settings.notificationEmail, + emailStockReminders: settings.emailStockReminders, + emailIntakeReminders: settings.emailIntakeReminders, + // Only include sensitive data if requested + shoutrrrEnabled: includeSensitive ? settings.shoutrrrEnabled : undefined, + shoutrrrUrl: includeSensitive ? settings.shoutrrrUrl : undefined, + shoutrrrStockReminders: settings.shoutrrrStockReminders, + shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders, + reminderDaysBefore: settings.reminderDaysBefore, + repeatDailyReminders: settings.repeatDailyReminders, + skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses, + repeatRemindersEnabled: settings.repeatRemindersEnabled, + reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes, + maxNaggingReminders: settings.maxNaggingReminders, + lowStockDays: settings.lowStockDays, + normalStockDays: settings.normalStockDays, + highStockDays: settings.highStockDays, + language: settings.language, + stockCalculationMode: settings.stockCalculationMode, + } : undefined; + + // 4. Load share links + const shares = await db.select().from(shareTokens).where(eq(shareTokens.userId, userId)); + + const exportShareLinks = shares.map((share) => ({ + takenBy: share.takenBy, + scheduleDays: share.scheduleDays, + expiresAt: share.expiresAt?.toISOString() ?? null, + regenerateToken: true, // Always regenerate tokens on import for security + })); + + // Build export object + const exportData = { + version: EXPORT_VERSION, + exportedAt: new Date().toISOString(), + includeSensitiveData: includeSensitive, + medications: exportMedications, + doseHistory: exportDoseHistory, + settings: exportSettings, + shareLinks: exportShareLinks, + }; + + // Set download headers + const filename = `medassist-export-${new Date().toISOString().split("T")[0]}.json`; + reply.header("Content-Type", "application/json"); + reply.header("Content-Disposition", `attachment; filename="${filename}"`); + + return exportData; + } + ); + + // --------------------------------------------------------------------------- + // POST /import - Import user data (replaces all existing data!) + // --------------------------------------------------------------------------- + app.post( + "/import", + async (request, reply) => { + const userId = await getUserId(request, reply); + + // 1. Parse and validate import data + const parsed = importDataSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ + error: "Invalid import data format", + details: parsed.error.format(), + }); + } + + const importData = parsed.data; + + // 2. Delete all existing user data (in correct order to respect foreign keys) + // Note: CASCADE delete should handle this, but let's be explicit + + // First, delete images for existing medications + const existingMeds = await db.select().from(medications).where(eq(medications.userId, userId)); + for (const med of existingMeds) { + if (med.imageUrl) { + const imagePath = resolve(IMAGES_DIR, med.imageUrl); + if (existsSync(imagePath)) { + try { unlinkSync(imagePath); } catch { /* ignore */ } + } + } + } + + // Delete in order: doses, share tokens, medications, settings + await db.delete(doseTracking).where(eq(doseTracking.userId, userId)); + await db.delete(shareTokens).where(eq(shareTokens.userId, userId)); + await db.delete(medications).where(eq(medications.userId, userId)); + await db.delete(userSettings).where(eq(userSettings.userId, userId)); + + // 3. Import medications and build ID mapping + const exportIdToNewId = new Map(); + + for (const med of importData.medications) { + // Convert schedules back to JSON arrays + const usageJson = JSON.stringify(med.schedules.map((s) => s.usage)); + const everyJson = JSON.stringify(med.schedules.map((s) => s.every)); + const startJson = JSON.stringify(med.schedules.map((s) => s.start)); + const takenByJson = JSON.stringify(med.takenBy); + + // Check if any schedule has remind enabled + const intakeRemindersEnabled = med.schedules.some((s) => s.remind) || med.intakeRemindersEnabled; + + const [inserted] = await db.insert(medications).values({ + userId, + name: med.name, + genericName: med.genericName || null, + takenByJson, + packCount: med.inventory.packCount, + blistersPerPack: med.inventory.blistersPerPack, + pillsPerBlister: med.inventory.pillsPerBlister, + looseTablets: med.inventory.looseTablets, + pillWeightMg: med.pillWeightMg || null, + usageJson, + everyJson, + startJson, + expiryDate: med.expiryDate || null, + notes: med.notes || null, + intakeRemindersEnabled, + imageUrl: null, // Will be set after image is saved + }).returning(); + + // Save mapping + exportIdToNewId.set(med._exportId, inserted.id); + + // Save image if present + if (med.image) { + const imageUrl = base64ToImage(med.image, inserted.id); + if (imageUrl) { + await db.update(medications) + .set({ imageUrl }) + .where(eq(medications.id, inserted.id)); + } + } + } + + // 4. Import dose history with remapped medication IDs + for (const dose of importData.doseHistory) { + const newMedId = exportIdToNewId.get(dose.medicationRef); + if (!newMedId) continue; // Skip orphaned doses + + // Convert ISO timestamp back to milliseconds for dose ID + const timestampMs = new Date(dose.scheduledTime).getTime(); + const doseId = buildDoseId(newMedId, dose.scheduleIndex, timestampMs); + + await db.insert(doseTracking).values({ + userId, + doseId, + takenAt: new Date(dose.takenAt), + markedBy: dose.markedBy || null, + }); + } + + // 5. Import settings + if (importData.settings) { + await db.insert(userSettings).values({ + userId, + emailEnabled: importData.settings.emailEnabled ?? false, + notificationEmail: importData.settings.notificationEmail || null, + emailStockReminders: importData.settings.emailStockReminders ?? true, + emailIntakeReminders: importData.settings.emailIntakeReminders ?? true, + shoutrrrEnabled: importData.settings.shoutrrrEnabled ?? false, + shoutrrrUrl: importData.settings.shoutrrrUrl || null, + shoutrrrStockReminders: importData.settings.shoutrrrStockReminders ?? true, + shoutrrrIntakeReminders: importData.settings.shoutrrrIntakeReminders ?? true, + reminderDaysBefore: importData.settings.reminderDaysBefore ?? 7, + repeatDailyReminders: importData.settings.repeatDailyReminders ?? false, + skipRemindersForTakenDoses: importData.settings.skipRemindersForTakenDoses ?? false, + repeatRemindersEnabled: importData.settings.repeatRemindersEnabled ?? false, + reminderRepeatIntervalMinutes: importData.settings.reminderRepeatIntervalMinutes ?? 30, + maxNaggingReminders: importData.settings.maxNaggingReminders ?? 5, + lowStockDays: importData.settings.lowStockDays ?? 30, + normalStockDays: importData.settings.normalStockDays ?? 90, + highStockDays: importData.settings.highStockDays ?? 180, + language: importData.settings.language ?? "en", + stockCalculationMode: importData.settings.stockCalculationMode ?? "automatic", + }); + } + + // 6. Import share links (with new tokens) + for (const share of importData.shareLinks) { + // Always generate new token for security + const token = randomBytes(8).toString("hex"); + + await db.insert(shareTokens).values({ + userId, + token, + takenBy: share.takenBy, + scheduleDays: share.scheduleDays, + expiresAt: share.expiresAt ? new Date(share.expiresAt) : null, + }); + } + + return { + success: true, + imported: { + medications: importData.medications.length, + doseHistory: importData.doseHistory.length, + settings: importData.settings ? 1 : 0, + shareLinks: importData.shareLinks.length, + }, + }; + } + ); +} diff --git a/backend/src/test/export.test.ts b/backend/src/test/export.test.ts new file mode 100644 index 0000000..e9a84aa --- /dev/null +++ b/backend/src/test/export.test.ts @@ -0,0 +1,851 @@ +/** + * Tests for /export and /import API endpoints. + * Tests export/import functionality with schema-independent format. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import { + buildTestApp, + closeTestApp, + clearTestData, + createTestUser, + createTestMedication, + TestContext, +} from "./setup.js"; +import { randomBytes } from "crypto"; + +// ============================================================================= +// Route Registration (simplified test routes) +// ============================================================================= + +async function registerExportRoutes(ctx: TestContext) { + const { app, client } = ctx; + const userId = 1; // Test user ID + + // Helper to parse blisters from DB + function parseBlisters(row: any): Array<{ usage: number; every: number; start: string; remind: boolean }> { + const usage = JSON.parse(row.usage_json || "[]") as number[]; + const every = JSON.parse(row.every_json || "[]") as number[]; + const start = JSON.parse(row.start_json || "[]") as string[]; + const len = Math.min(usage.length, every.length, start.length); + return Array.from({ length: len }, (_, i) => ({ + usage: usage[i], + every: every[i], + start: start[i], + remind: Boolean(row.intake_reminders_enabled), + })); + } + + // GET /export + app.get<{ Querystring: { includeSensitive?: string } }>("/export", async (request, reply) => { + const includeSensitive = request.query.includeSensitive === "true"; + + // Load medications + const medsResult = await client.execute({ + sql: `SELECT * FROM medications WHERE user_id = ? ORDER BY id`, + args: [userId], + }); + + const medIdToExportId = new Map(); + const medications = medsResult.rows.map((m, i) => { + const exportId = `med-${i + 1}`; + medIdToExportId.set(m.id as number, exportId); + return { + _exportId: exportId, + name: m.name, + genericName: m.generic_name, + takenBy: JSON.parse((m.taken_by_json as string) || "[]"), + inventory: { + packCount: m.pack_count ?? 1, + blistersPerPack: m.blisters_per_pack ?? 1, + pillsPerBlister: m.pills_per_blister ?? 1, + looseTablets: m.loose_tablets ?? 0, + }, + pillWeightMg: m.pill_weight_mg, + schedules: parseBlisters(m), + expiryDate: m.expiry_date, + notes: m.notes, + intakeRemindersEnabled: Boolean(m.intake_reminders_enabled), + image: null, // Skip images in test + }; + }); + + // Load dose tracking + const dosesResult = await client.execute({ + sql: `SELECT * FROM dose_tracking WHERE user_id = ?`, + args: [userId], + }); + + const doseHistory = dosesResult.rows + .map((d) => { + const parts = (d.dose_id as string).split("-"); + if (parts.length < 3) return null; + const medId = parseInt(parts[0], 10); + const exportId = medIdToExportId.get(medId); + if (!exportId) return null; + return { + medicationRef: exportId, + scheduleIndex: parseInt(parts[1], 10), + scheduledTime: new Date(parseInt(parts[2], 10)).toISOString(), + takenAt: d.taken_at ? new Date(d.taken_at as number * 1000).toISOString() : new Date().toISOString(), + markedBy: d.marked_by, + }; + }) + .filter(Boolean); + + // Load settings + const settingsResult = await client.execute({ + sql: `SELECT * FROM user_settings WHERE user_id = ?`, + args: [userId], + }); + + let settings = undefined; + if (settingsResult.rows.length > 0) { + const s = settingsResult.rows[0]; + settings = { + emailEnabled: Boolean(s.email_enabled), + notificationEmail: s.notification_email, + emailStockReminders: Boolean(s.email_stock_reminders ?? 1), + emailIntakeReminders: Boolean(s.email_intake_reminders ?? 1), + shoutrrrEnabled: includeSensitive ? Boolean(s.shoutrrr_enabled) : undefined, + shoutrrrUrl: includeSensitive ? s.shoutrrr_url : undefined, + shoutrrrStockReminders: Boolean(s.shoutrrr_stock_reminders ?? 1), + shoutrrrIntakeReminders: Boolean(s.shoutrrr_intake_reminders ?? 1), + reminderDaysBefore: s.reminder_days_before ?? 7, + repeatDailyReminders: Boolean(s.repeat_daily_reminders), + skipRemindersForTakenDoses: Boolean(s.skip_reminders_for_taken_doses), + repeatRemindersEnabled: Boolean(s.repeat_reminders_enabled), + reminderRepeatIntervalMinutes: s.reminder_repeat_interval_minutes ?? 30, + maxNaggingReminders: s.max_nagging_reminders ?? 5, + lowStockDays: s.low_stock_days ?? 30, + normalStockDays: s.normal_stock_days ?? 90, + highStockDays: s.high_stock_days ?? 180, + language: s.language ?? "en", + stockCalculationMode: s.stock_calculation_mode ?? "automatic", + }; + } + + // Load share links + const sharesResult = await client.execute({ + sql: `SELECT * FROM share_tokens WHERE user_id = ?`, + args: [userId], + }); + + const shareLinks = sharesResult.rows.map((s) => ({ + takenBy: s.taken_by, + scheduleDays: s.schedule_days ?? 30, + expiresAt: s.expires_at ? new Date(s.expires_at as number * 1000).toISOString() : null, + regenerateToken: true, + })); + + return { + version: "1.0", + exportedAt: new Date().toISOString(), + includeSensitiveData: includeSensitive, + medications, + doseHistory, + settings, + shareLinks, + }; + }); + + // POST /import + app.post<{ Body: any }>("/import", async (request, reply) => { + const importData = request.body as any; + + // Basic validation + if (!importData.version) { + return reply.status(400).send({ error: "Invalid import data format" }); + } + + // Delete existing data + await client.execute({ sql: `DELETE FROM dose_tracking WHERE user_id = ?`, args: [userId] }); + await client.execute({ sql: `DELETE FROM share_tokens WHERE user_id = ?`, args: [userId] }); + await client.execute({ sql: `DELETE FROM medications WHERE user_id = ?`, args: [userId] }); + await client.execute({ sql: `DELETE FROM user_settings WHERE user_id = ?`, args: [userId] }); + + // Import medications + const exportIdToNewId = new Map(); + for (const med of importData.medications || []) { + const usageJson = JSON.stringify((med.schedules || []).map((s: any) => s.usage)); + const everyJson = JSON.stringify((med.schedules || []).map((s: any) => s.every)); + const startJson = JSON.stringify((med.schedules || []).map((s: any) => s.start)); + const takenByJson = JSON.stringify(med.takenBy || []); + + const result = await client.execute({ + sql: `INSERT INTO medications ( + user_id, name, generic_name, taken_by_json, + pack_count, blisters_per_pack, pills_per_blister, loose_tablets, + pill_weight_mg, expiry_date, notes, intake_reminders_enabled, + usage_json, every_json, start_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`, + args: [ + userId, + med.name, + med.genericName || null, + takenByJson, + med.inventory?.packCount ?? 1, + med.inventory?.blistersPerPack ?? 1, + med.inventory?.pillsPerBlister ?? 1, + med.inventory?.looseTablets ?? 0, + med.pillWeightMg ?? null, + med.expiryDate || null, + med.notes || null, + med.intakeRemindersEnabled ? 1 : 0, + usageJson, + everyJson, + startJson, + ], + }); + + exportIdToNewId.set(med._exportId, result.rows[0].id as number); + } + + // Import dose history + for (const dose of importData.doseHistory || []) { + const newMedId = exportIdToNewId.get(dose.medicationRef); + if (!newMedId) continue; + + const timestampMs = new Date(dose.scheduledTime).getTime(); + const doseId = `${newMedId}-${dose.scheduleIndex}-${timestampMs}`; + + await client.execute({ + sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, marked_by) VALUES (?, ?, ?, ?)`, + args: [ + userId, + doseId, + Math.floor(new Date(dose.takenAt).getTime() / 1000), + dose.markedBy || null, + ], + }); + } + + // Import settings + if (importData.settings) { + const s = importData.settings; + await client.execute({ + sql: `INSERT INTO user_settings ( + user_id, email_enabled, notification_email, + email_stock_reminders, email_intake_reminders, + shoutrrr_enabled, shoutrrr_url, + shoutrrr_stock_reminders, shoutrrr_intake_reminders, + reminder_days_before, repeat_daily_reminders, + skip_reminders_for_taken_doses, repeat_reminders_enabled, + reminder_repeat_interval_minutes, max_nagging_reminders, + low_stock_days, normal_stock_days, high_stock_days, + language, stock_calculation_mode + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + args: [ + userId, + s.emailEnabled ? 1 : 0, + s.notificationEmail || null, + s.emailStockReminders ?? 1, + s.emailIntakeReminders ?? 1, + s.shoutrrrEnabled ? 1 : 0, + s.shoutrrrUrl || null, + s.shoutrrrStockReminders ?? 1, + s.shoutrrrIntakeReminders ?? 1, + s.reminderDaysBefore ?? 7, + s.repeatDailyReminders ? 1 : 0, + s.skipRemindersForTakenDoses ? 1 : 0, + s.repeatRemindersEnabled ? 1 : 0, + s.reminderRepeatIntervalMinutes ?? 30, + s.maxNaggingReminders ?? 5, + s.lowStockDays ?? 30, + s.normalStockDays ?? 90, + s.highStockDays ?? 180, + s.language ?? "en", + s.stockCalculationMode ?? "automatic", + ], + }); + } + + // Import share links + for (const share of importData.shareLinks || []) { + const token = randomBytes(8).toString("hex"); + await client.execute({ + sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, expires_at) VALUES (?, ?, ?, ?, ?)`, + args: [ + userId, + token, + share.takenBy, + share.scheduleDays ?? 30, + share.expiresAt ? Math.floor(new Date(share.expiresAt).getTime() / 1000) : null, + ], + }); + } + + return { + success: true, + imported: { + medications: (importData.medications || []).length, + doseHistory: (importData.doseHistory || []).length, + settings: importData.settings ? 1 : 0, + shareLinks: (importData.shareLinks || []).length, + }, + }; + }); +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe("Export/Import API", () => { + let ctx: TestContext; + let userId: number; + + beforeAll(async () => { + ctx = await buildTestApp(); + await registerExportRoutes(ctx); + await ctx.app.ready(); + }); + + afterAll(async () => { + await closeTestApp(ctx); + }); + + beforeEach(async () => { + await clearTestData(ctx.client); + await ctx.client.execute("DELETE FROM sqlite_sequence WHERE name='users'"); + await ctx.client.execute("DELETE FROM sqlite_sequence WHERE name='medications'"); + userId = await createTestUser(ctx.client, { username: "testuser" }); + }); + + // --------------------------------------------------------------------------- + // GET /export + // --------------------------------------------------------------------------- + + describe("GET /export", () => { + it("should export empty data for new user", async () => { + const response = await ctx.app.inject({ + method: "GET", + url: "/export", + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data.version).toBe("1.0"); + expect(data.exportedAt).toBeDefined(); + expect(data.medications).toEqual([]); + expect(data.doseHistory).toEqual([]); + expect(data.shareLinks).toEqual([]); + }); + + it("should export medications with correct format", async () => { + const startDate = "2025-01-15T08:00:00.000Z"; + await createTestMedication(ctx.client, { + userId, + name: "Aspirin", + genericName: "Acetylsalicylic acid", + takenBy: ["Daniel", "Maria"], + packCount: 2, + blistersPerPack: 3, + pillsPerBlister: 10, + looseTablets: 5, + pillWeightMg: 500, + expiryDate: "2027-06-30", + notes: "Take with food", + intakeRemindersEnabled: true, + blisters: [ + { usage: 1, every: 1, start: startDate }, + { usage: 0.5, every: 7, start: startDate }, + ], + }); + + const response = await ctx.app.inject({ + method: "GET", + url: "/export", + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data.medications).toHaveLength(1); + + const med = data.medications[0]; + expect(med._exportId).toBe("med-1"); + expect(med.name).toBe("Aspirin"); + expect(med.genericName).toBe("Acetylsalicylic acid"); + expect(med.takenBy).toEqual(["Daniel", "Maria"]); + expect(med.inventory).toEqual({ + packCount: 2, + blistersPerPack: 3, + pillsPerBlister: 10, + looseTablets: 5, + }); + expect(med.pillWeightMg).toBe(500); + expect(med.expiryDate).toBe("2027-06-30"); + expect(med.notes).toBe("Take with food"); + expect(med.intakeRemindersEnabled).toBe(true); + expect(med.schedules).toHaveLength(2); + expect(med.schedules[0]).toEqual({ + usage: 1, + every: 1, + start: startDate, + remind: true, + }); + }); + + it("should export settings", async () => { + // Create settings + await ctx.client.execute({ + sql: `INSERT INTO user_settings ( + user_id, email_enabled, notification_email, language, low_stock_days + ) VALUES (?, 1, 'test@example.com', 'de', 14)`, + args: [userId], + }); + + const response = await ctx.app.inject({ + method: "GET", + url: "/export", + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data.settings).toBeDefined(); + expect(data.settings.emailEnabled).toBe(true); + expect(data.settings.notificationEmail).toBe("test@example.com"); + expect(data.settings.language).toBe("de"); + expect(data.settings.lowStockDays).toBe(14); + }); + + it("should exclude sensitive data by default", async () => { + await ctx.client.execute({ + sql: `INSERT INTO user_settings ( + user_id, shoutrrr_enabled, shoutrrr_url + ) VALUES (?, 1, 'ntfy://user:pass@ntfy.sh/topic')`, + args: [userId], + }); + + const response = await ctx.app.inject({ + method: "GET", + url: "/export", + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data.includeSensitiveData).toBe(false); + expect(data.settings.shoutrrrEnabled).toBeUndefined(); + expect(data.settings.shoutrrrUrl).toBeUndefined(); + }); + + it("should include sensitive data when requested", async () => { + await ctx.client.execute({ + sql: `INSERT INTO user_settings ( + user_id, shoutrrr_enabled, shoutrrr_url + ) VALUES (?, 1, 'ntfy://user:pass@ntfy.sh/topic')`, + args: [userId], + }); + + const response = await ctx.app.inject({ + method: "GET", + url: "/export?includeSensitive=true", + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data.includeSensitiveData).toBe(true); + expect(data.settings.shoutrrrEnabled).toBe(true); + expect(data.settings.shoutrrrUrl).toBe("ntfy://user:pass@ntfy.sh/topic"); + }); + + it("should export dose history with medication references", async () => { + const medId = await createTestMedication(ctx.client, { + userId, + name: "Test Med", + }); + + // Create dose tracking entry + const timestampMs = Date.now(); + const doseId = `${medId}-0-${timestampMs}`; + await ctx.client.execute({ + sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at) VALUES (?, ?, ?)`, + args: [userId, doseId, Math.floor(Date.now() / 1000)], + }); + + const response = await ctx.app.inject({ + method: "GET", + url: "/export", + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data.doseHistory).toHaveLength(1); + expect(data.doseHistory[0].medicationRef).toBe("med-1"); + expect(data.doseHistory[0].scheduleIndex).toBe(0); + expect(data.doseHistory[0].scheduledTime).toBeDefined(); + expect(data.doseHistory[0].takenAt).toBeDefined(); + }); + + it("should export share links", async () => { + await ctx.client.execute({ + sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days) VALUES (?, ?, ?, ?)`, + args: [userId, "abc123", "Daniel", 30], + }); + + const response = await ctx.app.inject({ + method: "GET", + url: "/export", + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data.shareLinks).toHaveLength(1); + expect(data.shareLinks[0].takenBy).toBe("Daniel"); + expect(data.shareLinks[0].scheduleDays).toBe(30); + expect(data.shareLinks[0].regenerateToken).toBe(true); + }); + }); + + // --------------------------------------------------------------------------- + // POST /import + // --------------------------------------------------------------------------- + + describe("POST /import", () => { + it("should import medications", async () => { + const importData = { + version: "1.0", + exportedAt: new Date().toISOString(), + medications: [ + { + _exportId: "med-1", + name: "Imported Med", + genericName: "Generic", + takenBy: ["Alice"], + inventory: { + packCount: 2, + blistersPerPack: 3, + pillsPerBlister: 10, + looseTablets: 5, + }, + pillWeightMg: 250, + schedules: [ + { usage: 1, every: 1, start: "2025-01-15T08:00:00.000Z", remind: true }, + ], + expiryDate: "2027-12-31", + notes: "Test notes", + intakeRemindersEnabled: true, + }, + ], + doseHistory: [], + shareLinks: [], + }; + + const response = await ctx.app.inject({ + method: "POST", + url: "/import", + payload: importData, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().success).toBe(true); + expect(response.json().imported.medications).toBe(1); + + // Verify in database + const result = await ctx.client.execute({ + sql: `SELECT * FROM medications WHERE user_id = ?`, + args: [userId], + }); + expect(result.rows).toHaveLength(1); + expect(result.rows[0].name).toBe("Imported Med"); + expect(result.rows[0].generic_name).toBe("Generic"); + expect(result.rows[0].pack_count).toBe(2); + expect(result.rows[0].blisters_per_pack).toBe(3); + expect(result.rows[0].pills_per_blister).toBe(10); + expect(result.rows[0].loose_tablets).toBe(5); + }); + + it("should replace existing data on import", async () => { + // Create existing medication + await createTestMedication(ctx.client, { + userId, + name: "Existing Med", + }); + + const importData = { + version: "1.0", + exportedAt: new Date().toISOString(), + medications: [ + { + _exportId: "med-1", + name: "New Med", + schedules: [{ usage: 1, every: 1, start: "2025-01-15T08:00:00.000Z" }], + }, + ], + doseHistory: [], + shareLinks: [], + }; + + await ctx.app.inject({ + method: "POST", + url: "/import", + payload: importData, + }); + + // Verify old med deleted, new one exists + const result = await ctx.client.execute({ + sql: `SELECT * FROM medications WHERE user_id = ?`, + args: [userId], + }); + expect(result.rows).toHaveLength(1); + expect(result.rows[0].name).toBe("New Med"); + }); + + it("should import dose history with remapped IDs", async () => { + const importData = { + version: "1.0", + exportedAt: new Date().toISOString(), + medications: [ + { + _exportId: "med-1", + name: "Med 1", + schedules: [{ usage: 1, every: 1, start: "2025-01-15T08:00:00.000Z" }], + }, + ], + doseHistory: [ + { + medicationRef: "med-1", + scheduleIndex: 0, + scheduledTime: "2025-01-15T08:00:00.000Z", + takenAt: "2025-01-15T08:15:00.000Z", + markedBy: null, + }, + ], + shareLinks: [], + }; + + await ctx.app.inject({ + method: "POST", + url: "/import", + payload: importData, + }); + + // Verify dose tracking + const doses = await ctx.client.execute({ + sql: `SELECT * FROM dose_tracking WHERE user_id = ?`, + args: [userId], + }); + expect(doses.rows).toHaveLength(1); + // Dose ID should contain the NEW medication ID + const doseId = doses.rows[0].dose_id as string; + expect(doseId).toMatch(/^\d+-0-\d+$/); + }); + + it("should import settings", async () => { + const importData = { + version: "1.0", + exportedAt: new Date().toISOString(), + medications: [], + doseHistory: [], + settings: { + emailEnabled: true, + notificationEmail: "imported@example.com", + language: "de", + lowStockDays: 14, + normalStockDays: 60, + highStockDays: 120, + }, + shareLinks: [], + }; + + await ctx.app.inject({ + method: "POST", + url: "/import", + payload: importData, + }); + + // Verify settings + const settings = await ctx.client.execute({ + sql: `SELECT * FROM user_settings WHERE user_id = ?`, + args: [userId], + }); + expect(settings.rows).toHaveLength(1); + expect(settings.rows[0].email_enabled).toBe(1); + expect(settings.rows[0].notification_email).toBe("imported@example.com"); + expect(settings.rows[0].language).toBe("de"); + expect(settings.rows[0].low_stock_days).toBe(14); + }); + + it("should import share links with new tokens", async () => { + const importData = { + version: "1.0", + exportedAt: new Date().toISOString(), + medications: [], + doseHistory: [], + shareLinks: [ + { + takenBy: "Daniel", + scheduleDays: 60, + regenerateToken: true, + }, + ], + }; + + await ctx.app.inject({ + method: "POST", + url: "/import", + payload: importData, + }); + + // Verify share token + const shares = await ctx.client.execute({ + sql: `SELECT * FROM share_tokens WHERE user_id = ?`, + args: [userId], + }); + expect(shares.rows).toHaveLength(1); + expect(shares.rows[0].taken_by).toBe("Daniel"); + expect(shares.rows[0].schedule_days).toBe(60); + expect(shares.rows[0].token).toBeDefined(); + expect((shares.rows[0].token as string).length).toBe(16); // 8 bytes = 16 hex chars + }); + + it("should reject invalid import data", async () => { + const response = await ctx.app.inject({ + method: "POST", + url: "/import", + payload: { invalid: "data" }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().error).toBe("Invalid import data format"); + }); + }); + + // --------------------------------------------------------------------------- + // Export/Import Roundtrip Tests + // --------------------------------------------------------------------------- + + describe("Export/Import Roundtrip", () => { + it("should preserve all data through export/import cycle", async () => { + // Setup: Create medications, doses, settings, shares + const startDate = "2025-01-15T08:00:00.000Z"; + const medId = await createTestMedication(ctx.client, { + userId, + name: "Roundtrip Med", + genericName: "Generic Name", + takenBy: ["Daniel", "Maria"], + packCount: 2, + blistersPerPack: 3, + pillsPerBlister: 10, + looseTablets: 5, + pillWeightMg: 500, + expiryDate: "2027-06-30", + notes: "Test notes", + intakeRemindersEnabled: true, + blisters: [ + { usage: 1, every: 1, start: startDate }, + { usage: 0.5, every: 7, start: startDate }, + ], + }); + + // Create dose + const timestampMs = new Date(startDate).getTime(); + const doseId = `${medId}-0-${timestampMs}`; + await ctx.client.execute({ + sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, marked_by) VALUES (?, ?, ?, ?)`, + args: [userId, doseId, Math.floor(Date.now() / 1000), "Daniel"], + }); + + // Create settings + await ctx.client.execute({ + sql: `INSERT INTO user_settings (user_id, email_enabled, notification_email, language, low_stock_days) VALUES (?, 1, 'test@example.com', 'de', 14)`, + args: [userId], + }); + + // Create share + await ctx.client.execute({ + sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days) VALUES (?, ?, ?, ?)`, + args: [userId, "original123", "Daniel", 60], + }); + + // Export + const exportResponse = await ctx.app.inject({ + method: "GET", + url: "/export", + }); + expect(exportResponse.statusCode).toBe(200); + const exportData = exportResponse.json(); + + // Import (this replaces all data) + const importResponse = await ctx.app.inject({ + method: "POST", + url: "/import", + payload: exportData, + }); + expect(importResponse.statusCode).toBe(200); + + // Export again and compare + const reExportResponse = await ctx.app.inject({ + method: "GET", + url: "/export", + }); + const reExportData = reExportResponse.json(); + + // Compare (excluding timestamps and IDs that change) + expect(reExportData.medications).toHaveLength(1); + expect(reExportData.medications[0].name).toBe("Roundtrip Med"); + expect(reExportData.medications[0].genericName).toBe("Generic Name"); + expect(reExportData.medications[0].takenBy).toEqual(["Daniel", "Maria"]); + expect(reExportData.medications[0].inventory).toEqual({ + packCount: 2, + blistersPerPack: 3, + pillsPerBlister: 10, + looseTablets: 5, + }); + expect(reExportData.medications[0].schedules).toHaveLength(2); + + expect(reExportData.doseHistory).toHaveLength(1); + expect(reExportData.doseHistory[0].markedBy).toBe("Daniel"); + + expect(reExportData.settings.emailEnabled).toBe(true); + expect(reExportData.settings.notificationEmail).toBe("test@example.com"); + expect(reExportData.settings.language).toBe("de"); + + expect(reExportData.shareLinks).toHaveLength(1); + expect(reExportData.shareLinks[0].takenBy).toBe("Daniel"); + }); + + it("should handle import with different schema (backward compatibility)", async () => { + // Simulate import from older version without some fields + const importData = { + version: "1.0", + exportedAt: new Date().toISOString(), + medications: [ + { + _exportId: "med-1", + name: "Legacy Med", + // Missing: genericName, takenBy, pillWeightMg, etc. + inventory: { + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 10, + looseTablets: 0, + }, + schedules: [ + { usage: 1, every: 1, start: "2025-01-15T08:00:00.000Z" }, + ], + }, + ], + doseHistory: [], + // Missing: settings, shareLinks + }; + + const response = await ctx.app.inject({ + method: "POST", + url: "/import", + payload: importData, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().success).toBe(true); + + // Verify defaults were applied + const result = await ctx.client.execute({ + sql: `SELECT * FROM medications WHERE user_id = ?`, + args: [userId], + }); + expect(result.rows[0].name).toBe("Legacy Med"); + expect(result.rows[0].generic_name).toBeNull(); + expect(result.rows[0].taken_by_json).toBe("[]"); + }); + }); +}); diff --git a/backend/src/test/setup.ts b/backend/src/test/setup.ts index 8fd4ef8..80f81d4 100644 --- a/backend/src/test/setup.ts +++ b/backend/src/test/setup.ts @@ -107,6 +107,9 @@ export interface CreateMedicationOptions { pillsPerBlister?: number; looseTablets?: number; pillWeightMg?: number; + expiryDate?: string | null; + notes?: string | null; + intakeRemindersEnabled?: boolean; /** Array of { usage, every, start } for each blister schedule */ blisters?: Array<{ usage: number; every: number; start: string }>; } @@ -128,6 +131,9 @@ export async function createTestMedication( pillsPerBlister = 10, looseTablets = 0, pillWeightMg = null, + expiryDate = null, + notes = null, + intakeRemindersEnabled = false, blisters = [{ usage: 1, every: 1, start: new Date().toISOString() }], } = options; @@ -141,8 +147,8 @@ export async function createTestMedication( sql: `INSERT INTO medications ( user_id, name, generic_name, taken_by_json, pack_count, blisters_per_pack, pills_per_blister, loose_tablets, - pill_weight_mg, usage_json, every_json, start_json - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`, + pill_weight_mg, usage_json, every_json, start_json, expiry_date, notes, intake_reminders_enabled + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`, args: [ userId, name, @@ -156,6 +162,9 @@ export async function createTestMedication( usageJson, everyJson, startJson, + expiryDate, + notes, + intakeRemindersEnabled ? 1 : 0, ], }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b91a101..ebe98cc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -351,6 +351,12 @@ function AppContent() { const [shareGenerating, setShareGenerating] = useState(false); const [shareLink, setShareLink] = useState(null); const [shareCopied, setShareCopied] = useState(false); + // Export/Import state + const [exporting, setExporting] = useState(false); + const [importing, setImporting] = useState(false); + const [includeSensitiveData, setIncludeSensitiveData] = useState(false); + const [showImportConfirm, setShowImportConfirm] = useState(false); + const [pendingImportData, setPendingImportData] = useState(null); // Collapsed days state (manually collapsed days are persisted) const [manuallyCollapsedDays, setManuallyCollapsedDays] = useState>(new Set()); const [manuallyExpandedDays, setManuallyExpandedDays] = useState>(new Set()); @@ -778,6 +784,110 @@ function AppContent() { setSendingReminderEmail(false); } + // Export data to JSON file + async function handleExport() { + setExporting(true); + try { + const res = await fetch(`/api/export?includeSensitive=${includeSensitiveData}`, { + credentials: "include", + }); + if (!res.ok) throw new Error("Export failed"); + const data = await res.json(); + + // Create download + const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + const dateStr = new Date().toISOString().split("T")[0]; + a.href = url; + a.download = `${t('exportImport.downloadFilename')}-${dateStr}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch (err) { + console.error("Export error:", err); + } + setExporting(false); + } + + // Handle file selection for import + function handleImportFileSelect(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (event) => { + try { + const data = JSON.parse(event.target?.result as string); + if (!data.version || !data.exportedAt) { + alert(t('exportImport.invalidFile')); + return; + } + setPendingImportData(data); + setShowImportConfirm(true); + } catch { + alert(t('exportImport.invalidFile')); + } + }; + reader.readAsText(file); + // Reset file input + e.target.value = ""; + } + + // Confirm and execute import + async function handleImportConfirm() { + if (!pendingImportData) return; + setImporting(true); + setShowImportConfirm(false); + + try { + const res = await fetch("/api/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify(pendingImportData), + }); + + if (!res.ok) { + const err = await res.json(); + alert(t('exportImport.importError') + ": " + (err.error || "Unknown error")); + return; + } + + const result = await res.json(); + alert(t('exportImport.importSuccess') + "\n" + t('exportImport.importSuccessDetails', { + medications: result.imported.medications, + doses: result.imported.doseHistory, + shares: result.imported.shareLinks, + })); + + // Reload all data + loadMeds(); + loadSettings(); + loadTakenDoses(); + } catch (err) { + console.error("Import error:", err); + alert(t('exportImport.importError')); + } + + setPendingImportData(null); + setImporting(false); + } + + // Helper function to load taken doses (extracted from useEffect) + async function loadTakenDoses() { + try { + const res = await fetch("/api/doses/taken", { credentials: "include" }); + if (res.ok) { + const data = await res.json(); + setTakenDoses(new Set(data.doses.map((d: { doseId: string }) => d.doseId))); + } + } catch { + // Silently fail + } + } + async function deleteMed(id: number) { await fetch(`/api/medications/${id}`, { method: "DELETE" }).catch(() => null); if (editingId === id) resetForm(); @@ -2299,6 +2409,70 @@ function AppContent() { + {/* Export/Import Section */} +
+
+

{t('exportImport.title')}

+
+
+

{t('exportImport.description')}

+ + {/* Export */} +
+
+ + {includeSensitiveData && ( +

+ ⚠️ {t('exportImport.sensitiveWarning')} +

+ )} + +
+
+ + {/* Import */} +
+
+ +
+
+
+
+
)} + + {/* Import Confirmation Modal */} + {showImportConfirm && ( +
setShowImportConfirm(false)}> +
e.stopPropagation()} style={{maxWidth: "450px"}}> + +

{t('exportImport.confirmImport')}

+

{t('exportImport.confirmImportMessage')}

+

+ ⚠️ {t('exportImport.confirmImportWarning')} +

+
+ + +
+
+
+ )} } /> diff --git a/frontend/src/i18n/de.json b/frontend/src/i18n/de.json index 9063f91..23621dd 100644 --- a/frontend/src/i18n/de.json +++ b/frontend/src/i18n/de.json @@ -348,5 +348,27 @@ "contact": "Bitte kontaktiere {{username}} um einen neuen Link anzufordern.", "expiredOn": "Abgelaufen am: {{date}}" } + }, + "exportImport": { + "title": "Daten Export / Import", + "description": "Exportiere deine Daten zur Sicherung oder Übertragung auf ein anderes Gerät. Import ersetzt ALLE deine bestehenden Daten.", + "export": "Daten exportieren", + "exporting": "Exportiere...", + "import": "Daten importieren", + "importing": "Importiere...", + "selectFile": "Datei auswählen", + "includeSensitive": "Sensible Daten einschließen", + "sensitiveWarning": "Warnung: Dies fügt Benachrichtigungs-URLs (können Passwörter enthalten) im Klartext in die Exportdatei ein.", + "confirmImport": "Alle Daten ersetzen?", + "confirmImportMessage": "Dies löscht dauerhaft alle deine aktuellen Medikamente, Einnahmehistorie, Einstellungen und Teilen-Links und ersetzt sie durch die importierten Daten.", + "confirmImportWarning": "Diese Aktion kann nicht rückgängig gemacht werden!", + "confirmButton": "Ja, alles ersetzen", + "cancelButton": "Abbrechen", + "exportSuccess": "Daten erfolgreich exportiert", + "importSuccess": "Daten erfolgreich importiert", + "importSuccessDetails": "Importiert: {{medications}} Medikamente, {{doses}} Dosen, {{shares}} Teilen-Links", + "importError": "Daten konnten nicht importiert werden", + "invalidFile": "Ungültiges Dateiformat. Bitte wähle eine gültige MedAssist-Exportdatei.", + "downloadFilename": "medassist-export" } } diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 40daa43..644439c 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -350,5 +350,27 @@ "contact": "Please contact {{username}} to request a new link.", "expiredOn": "Expired on: {{date}}" } + }, + "exportImport": { + "title": "Data Export / Import", + "description": "Export your data for backup or transfer to another device. Import will replace ALL your existing data.", + "export": "Export Data", + "exporting": "Exporting...", + "import": "Import Data", + "importing": "Importing...", + "selectFile": "Select File", + "includeSensitive": "Include sensitive data", + "sensitiveWarning": "Warning: This will include notification URLs (may contain passwords) in plain text in the export file.", + "confirmImport": "Replace All Data?", + "confirmImportMessage": "This will permanently delete all your current medications, dose history, settings, and share links, then replace them with the imported data.", + "confirmImportWarning": "This action cannot be undone!", + "confirmButton": "Yes, Replace All", + "cancelButton": "Cancel", + "exportSuccess": "Data exported successfully", + "importSuccess": "Data imported successfully", + "importSuccessDetails": "Imported: {{medications}} medications, {{doses}} doses, {{shares}} share links", + "importError": "Failed to import data", + "invalidFile": "Invalid file format. Please select a valid MedAssist export file.", + "downloadFilename": "medassist-export" } }