Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 718157e472 | |||
| f00f11aa55 | |||
| 4081e03970 | |||
| 9cfbf89d46 | |||
| ffab9ef4da | |||
| ed707444a2 |
+164
-86
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
## General Rules
|
## 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.
|
||||||
|
- **NEVER release without explicit permission**: Do NOT create tags, releases, or version bumps unless the user explicitly asks for it. Always wait for explicit confirmation before any release action.
|
||||||
- **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.
|
- **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.
|
- **Clean workspace**: Always clean up after yourself. If you create a file for a specific task, delete it once done.
|
||||||
|
|
||||||
@@ -46,25 +48,25 @@ cd backend && npm run test:coverage # Run with coverage report
|
|||||||
|
|
||||||
## Testing (MANDATORY)
|
## Testing (MANDATORY)
|
||||||
|
|
||||||
> ⚠️ **WICHTIG**: Jede neue Funktionalität MUSS mit Tests abgedeckt werden!
|
> ⚠️ **IMPORTANT**: Every new feature MUST be covered by tests!
|
||||||
> Pull Requests ohne Tests für neue Features werden nicht akzeptiert.
|
> Pull Requests without tests for new features will not be accepted.
|
||||||
|
|
||||||
### Test-Framework
|
### Test Framework
|
||||||
- **Vitest 2.1** mit v8 Coverage
|
- **Vitest 2.1** with v8 Coverage
|
||||||
- Tests in `backend/src/test/*.test.ts`
|
- 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
|
### Test Structure
|
||||||
| Datei | Testet |
|
| File | Tests |
|
||||||
|-------|--------|
|
|------|-------|
|
||||||
| `routes.test.ts` | API-Endpunkte (Auth, Medications, Doses, Settings, Share, Planner) |
|
| `routes.test.ts` | API endpoints (Auth, Medications, Doses, Settings, Share, Planner) |
|
||||||
| `services.test.ts` | Scheduler-Utilities (Timezone, Blisters, Usage-Berechnung) |
|
| `services.test.ts` | Scheduler utilities (Timezone, Blisters, Usage calculation) |
|
||||||
| `db.test.ts` | Datenbank-Schema und Operationen |
|
| `db.test.ts` | Database schema and operations |
|
||||||
|
|
||||||
### Tests schreiben
|
### Writing Tests
|
||||||
|
|
||||||
```typescript
|
```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 { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
import { createTestApp, createTestUser } from './routes.test'; // Test-Utilities
|
import { createTestApp, createTestUser } from './routes.test'; // Test-Utilities
|
||||||
|
|
||||||
@@ -95,24 +97,24 @@ describe('Feature Name', () => {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Test-Commands
|
### Test Commands
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
CI=true npm test # Tests einmal ausführen (IMMER so ausführen!)
|
CI=true npm test # Run tests once (ALWAYS run this way!)
|
||||||
CI=true npm run test:coverage # Mit Coverage-Report
|
CI=true npm run test:coverage # With coverage report
|
||||||
npm test -- --watch # Watch-Mode für manuelle Entwicklung
|
npm test -- --watch # Watch mode for manual development
|
||||||
npm test -- -t "test name" # Einzelnen Test ausführen
|
npm test -- -t "test name" # Run single test
|
||||||
```
|
```
|
||||||
|
|
||||||
> ⚠️ **WICHTIG für AI-Agenten**: Tests IMMER mit `CI=true` ausführen!
|
> ⚠️ **IMPORTANT for AI agents**: ALWAYS run tests with `CI=true`!
|
||||||
> Ohne `CI=true` läuft Vitest im Watch-Mode und wartet auf Eingaben.
|
> Without `CI=true`, Vitest runs in watch mode and waits for input.
|
||||||
|
|
||||||
## CI/CD Pipeline (GitHub Actions)
|
## CI/CD Pipeline (GitHub Actions)
|
||||||
|
|
||||||
### Workflow-Übersicht
|
### Workflow Overview
|
||||||
|
|
||||||
```
|
```
|
||||||
Pull Request erstellt
|
Pull Request created
|
||||||
↓
|
↓
|
||||||
┌─────────────────────────────────────┐
|
┌─────────────────────────────────────┐
|
||||||
│ test.yml │
|
│ test.yml │
|
||||||
@@ -124,65 +126,141 @@ Pull Request erstellt
|
|||||||
│ ├─ npm ci │
|
│ ├─ npm ci │
|
||||||
│ └─ npm run build │
|
│ └─ npm run build │
|
||||||
└─────────────────────────────────────┘
|
└─────────────────────────────────────┘
|
||||||
↓ Tests müssen bestehen
|
↓ Tests must pass
|
||||||
PR kann gemerged werden
|
PR can be merged
|
||||||
↓
|
↓
|
||||||
Push to main / Tag erstellt
|
Push to main / Tag created
|
||||||
↓
|
↓
|
||||||
┌─────────────────────────────────────┐
|
┌─────────────────────────────────────┐
|
||||||
│ docker-build.yml │
|
│ docker-build.yml │
|
||||||
│ ├─ backend-test (parallel) │
|
│ ├─ backend-test (parallel) │
|
||||||
│ ├─ frontend-build (parallel) │
|
│ ├─ frontend-build (parallel) │
|
||||||
│ └─ build-and-push (nach Tests) │
|
│ └─ build-and-push (after tests) │
|
||||||
│ ├─ Docker Images bauen │
|
│ ├─ Build Docker images │
|
||||||
│ └─ Push zu GHCR │
|
│ └─ Push to GHCR │
|
||||||
└─────────────────────────────────────┘
|
└─────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
### Branch Protection
|
### Branch Protection
|
||||||
|
|
||||||
> ⚠️ **WICHTIG**: Der `main` Branch ist geschützt!
|
> ⚠️ **IMPORTANT**: The `main` branch is protected!
|
||||||
> Direktes Pushen nach `main` ist **nicht möglich** - GitHub lehnt den Push ab.
|
> Direct pushing to `main` is **not possible** - GitHub will reject the push.
|
||||||
> Alle Änderungen müssen über Pull Requests erfolgen.
|
> All changes must go through Pull Requests.
|
||||||
|
|
||||||
- **main** Branch ist geschützt (Repository Rules)
|
- **main** branch is protected (Repository Rules)
|
||||||
- Direktes Pushen wird von GitHub abgelehnt mit: `GH013: Repository rule violations`
|
- Direct pushing is rejected by GitHub with: `GH013: Repository rule violations`
|
||||||
- PRs benötigen:
|
- PRs require:
|
||||||
- ✅ `backend-test` Status Check bestanden
|
- ✅ `backend-test` Status Check passed
|
||||||
- ✅ `frontend-build` Status Check bestanden
|
- ✅ `frontend-build` Status Check passed
|
||||||
- Nach erfolgreichem Merge wird der Feature-Branch automatisch gelöscht
|
- After successful merge, the feature branch is automatically deleted
|
||||||
|
|
||||||
**Workflow für Änderungen:**
|
**Workflow for changes:**
|
||||||
```bash
|
```bash
|
||||||
# 1. Feature Branch erstellen
|
# 1. Create feature branch
|
||||||
git checkout -b feat/mein-feature
|
git checkout -b feat/my-feature
|
||||||
|
|
||||||
# 2. Änderungen committen und pushen
|
# 2. Commit and push changes
|
||||||
git add . && git commit -m "feat: Beschreibung"
|
git add . && git commit -m "feat: Description"
|
||||||
git push -u origin feat/mein-feature
|
git push -u origin feat/my-feature
|
||||||
|
|
||||||
# 3. PR erstellen (via GitHub CLI oder Web)
|
# 3. Create PR (via GitHub CLI or Web)
|
||||||
gh pr create --title "Mein Feature" --body "Beschreibung"
|
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
|
gh pr merge --squash --delete-branch
|
||||||
```
|
```
|
||||||
|
|
||||||
### Workflow-Dateien
|
### Workflow Files
|
||||||
| Datei | Trigger | Zweck |
|
| File | Trigger | Purpose |
|
||||||
|-------|---------|-------|
|
|------|---------|--------|
|
||||||
| `.github/workflows/test.yml` | Pull Requests | Tests ausführen, PR blockieren bei Fehlern |
|
| `.github/workflows/test.yml` | Pull Requests | Run tests, block PR on failures |
|
||||||
| `.github/workflows/docker-build.yml` | Push to main, Tags | Tests + Docker Images bauen und pushen |
|
| `.github/workflows/docker-build.yml` | Push to main, Tags | Tests + Build and push Docker images |
|
||||||
|
|
||||||
### Neuen Code hinzufügen - Checkliste
|
### Adding New Code - Checklist
|
||||||
1. ✅ Feature implementieren
|
1. ✅ Implement feature
|
||||||
2. ✅ Tests für das Feature schreiben
|
2. ✅ Write tests for the feature
|
||||||
3. ✅ Lokal `npm run test:coverage` ausführen
|
3. ✅ Run `npm run test:coverage` locally
|
||||||
4. ✅ Coverage darf nicht sinken
|
4. ✅ Coverage must not decrease
|
||||||
5. ✅ Feature Branch erstellen und pushen
|
5. ✅ Create and push feature branch
|
||||||
6. ✅ Pull Request erstellen
|
6. ✅ Create Pull Request
|
||||||
7. ✅ Warten bis CI grün ist
|
7. ✅ Wait until CI is green
|
||||||
8. ✅ PR mergen (Branch wird automatisch gelöscht)
|
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
|
## Key Patterns
|
||||||
|
|
||||||
@@ -371,54 +449,54 @@ Example: `5-0-1735344000000` = Medication 5, Blister 0, timestamp
|
|||||||
- **Environment**: Copy `.env.example` → `.env`, secrets must be 10+ chars
|
- **Environment**: Copy `.env.example` → `.env`, secrets must be 10+ chars
|
||||||
- **i18n**: All UI text via `t('key')` function, translations in `frontend/src/i18n/*.json`
|
- **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!
|
> ⚠️ **CRITICAL**: The app MUST remain backward compatible with older databases!
|
||||||
> Nutzer upgraden ihre Docker-Container, aber behalten ihre bestehende DB.
|
> Users upgrade their Docker containers but keep their existing DB.
|
||||||
> Die App darf NICHT abstürzen wenn alte Spalten fehlen.
|
> 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 <wert>` haben
|
1. **ALWAYS with DEFAULT value**: New columns must have `NOT NULL DEFAULT <value>`
|
||||||
2. **NULL-sicher im Code**: Alle Abfragen müssen `?? defaultValue` oder `?? false` verwenden
|
2. **NULL-safe in code**: All queries must use `?? defaultValue` or `?? false`
|
||||||
3. **Schema-SQL aktualisieren**: In diesen Dateien hinzufügen:
|
3. **Update schema SQL**: Add to these files:
|
||||||
- `backend/src/db/schema.ts` - Drizzle Schema
|
- `backend/src/db/schema.ts` - Drizzle Schema
|
||||||
- `backend/src/db/schema-sql.ts` - `getTableCreationSQL()` für neue DBs
|
- `backend/src/db/schema-sql.ts` - `getTableCreationSQL()` for new DBs
|
||||||
- `backend/src/db/client.ts` - `ALTER TABLE ADD COLUMN IF NOT EXISTS` Migration
|
- `backend/src/db/client.ts` - `ALTER TABLE ADD COLUMN IF NOT EXISTS` migration
|
||||||
4. **Test-Schemas updaten**: Alle Test-Dateien mit eigenem Schema:
|
4. **Update test schemas**: All test files with their own schema:
|
||||||
- `backend/src/test/e2e-routes.test.ts`
|
- `backend/src/test/e2e-routes.test.ts`
|
||||||
- `backend/src/test/integration.test.ts`
|
- `backend/src/test/integration.test.ts`
|
||||||
- `backend/src/test/planner.test.ts`
|
- `backend/src/test/planner.test.ts`
|
||||||
|
|
||||||
### Beispiel: Neue Spalte hinzufügen
|
### Example: Adding a New Column
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// 1. schema.ts - Drizzle Definition
|
// 1. schema.ts - Drizzle definition
|
||||||
maxNaggingReminders: integer("max_nagging_reminders").notNull().default(5),
|
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,"
|
"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(() => {});
|
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,
|
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
|
||||||
```
|
```
|
||||||
|
|
||||||
### Was NICHT erlaubt ist
|
### What is NOT Allowed
|
||||||
|
|
||||||
- ❌ Spalten löschen oder umbenennen (bricht alte DBs)
|
- ❌ Deleting or renaming columns (breaks old DBs)
|
||||||
- ❌ `NOT NULL` ohne `DEFAULT` (INSERT schlägt fehl)
|
- ❌ `NOT NULL` without `DEFAULT` (INSERT fails)
|
||||||
- ❌ Spalten ohne Fallback im Code lesen
|
- ❌ Reading columns without fallback in code
|
||||||
- ❌ DB löschen als "Lösung" dokumentieren
|
- ❌ 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:
|
If a breaking change is unavoidable:
|
||||||
1. **Explizit kommunizieren**: In Release Notes dokumentieren
|
1. **Explicitly communicate**: Document in release notes
|
||||||
2. **Migration-Script**: Automatisches Upgrade-Script bereitstellen
|
2. **Migration script**: Provide automatic upgrade script
|
||||||
3. **Versionsprüfung**: App sollte DB-Version prüfen und warnen
|
3. **Version check**: App should check DB version and warn
|
||||||
|
|
||||||
## File Locations
|
## File Locations
|
||||||
|
|
||||||
|
|||||||
@@ -16,11 +16,25 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Get previous tag
|
||||||
|
id: prev_tag
|
||||||
|
run: |
|
||||||
|
# Get all tags sorted by version, find the one before current
|
||||||
|
CURRENT_TAG=${GITHUB_REF#refs/tags/}
|
||||||
|
PREV_TAG=$(git tag --sort=-v:refname | grep -A1 "^${CURRENT_TAG}$" | tail -1)
|
||||||
|
|
||||||
|
# If no previous tag found (first release), use empty
|
||||||
|
if [ "$PREV_TAG" = "$CURRENT_TAG" ]; then
|
||||||
|
PREV_TAG=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "previous_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||||
|
echo "Current tag: $CURRENT_TAG, Previous tag: $PREV_TAG"
|
||||||
|
|
||||||
- name: Generate changelog
|
- name: Generate changelog
|
||||||
id: changelog
|
id: changelog
|
||||||
run: |
|
run: |
|
||||||
# Get previous tag
|
PREV_TAG="${{ steps.prev_tag.outputs.previous_tag }}"
|
||||||
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
|
|
||||||
|
|
||||||
if [ -z "$PREV_TAG" ]; then
|
if [ -z "$PREV_TAG" ]; then
|
||||||
# First release - get all commits
|
# First release - get all commits
|
||||||
@@ -37,6 +51,6 @@ jobs:
|
|||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v1
|
||||||
with:
|
with:
|
||||||
body_path: changelog.txt
|
body_path: changelog.txt
|
||||||
generate_release_notes: true
|
generate_release_notes: false
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { settingsRoutes } from "./routes/settings.js";
|
|||||||
import { plannerRoutes } from "./routes/planner.js";
|
import { plannerRoutes } from "./routes/planner.js";
|
||||||
import { shareRoutes } from "./routes/share.js";
|
import { shareRoutes } from "./routes/share.js";
|
||||||
import { doseRoutes } from "./routes/doses.js";
|
import { doseRoutes } from "./routes/doses.js";
|
||||||
|
import { exportRoutes } from "./routes/export.js";
|
||||||
import { startReminderScheduler } from "./services/reminder-scheduler.js";
|
import { startReminderScheduler } from "./services/reminder-scheduler.js";
|
||||||
import { startIntakeReminderScheduler } from "./services/intake-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(plannerRoutes);
|
||||||
await app.register(shareRoutes);
|
await app.register(shareRoutes);
|
||||||
await app.register(doseRoutes);
|
await app.register(doseRoutes);
|
||||||
|
await app.register(exportRoutes);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
@@ -181,6 +183,7 @@ await app.register(settingsRoutes);
|
|||||||
await app.register(plannerRoutes);
|
await app.register(plannerRoutes);
|
||||||
await app.register(shareRoutes);
|
await app.register(shareRoutes);
|
||||||
await app.register(doseRoutes);
|
await app.register(doseRoutes);
|
||||||
|
await app.register(exportRoutes);
|
||||||
|
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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<number> {
|
||||||
|
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<string, string> = {
|
||||||
|
".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<number, string>();
|
||||||
|
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<typeof d> => d !== null);
|
||||||
|
|
||||||
|
// 3. Load user settings
|
||||||
|
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
|
||||||
|
|
||||||
|
const exportSettings = settings ? {
|
||||||
|
emailEnabled: settings.emailEnabled,
|
||||||
|
notificationEmail: settings.notificationEmail,
|
||||||
|
emailStockReminders: settings.emailStockReminders,
|
||||||
|
emailIntakeReminders: settings.emailIntakeReminders,
|
||||||
|
// 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<string, number>();
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<number, string>();
|
||||||
|
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<string, number>();
|
||||||
|
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("[]");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -107,6 +107,9 @@ export interface CreateMedicationOptions {
|
|||||||
pillsPerBlister?: number;
|
pillsPerBlister?: number;
|
||||||
looseTablets?: number;
|
looseTablets?: number;
|
||||||
pillWeightMg?: number;
|
pillWeightMg?: number;
|
||||||
|
expiryDate?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
intakeRemindersEnabled?: boolean;
|
||||||
/** Array of { usage, every, start } for each blister schedule */
|
/** Array of { usage, every, start } for each blister schedule */
|
||||||
blisters?: Array<{ usage: number; every: number; start: string }>;
|
blisters?: Array<{ usage: number; every: number; start: string }>;
|
||||||
}
|
}
|
||||||
@@ -128,6 +131,9 @@ export async function createTestMedication(
|
|||||||
pillsPerBlister = 10,
|
pillsPerBlister = 10,
|
||||||
looseTablets = 0,
|
looseTablets = 0,
|
||||||
pillWeightMg = null,
|
pillWeightMg = null,
|
||||||
|
expiryDate = null,
|
||||||
|
notes = null,
|
||||||
|
intakeRemindersEnabled = false,
|
||||||
blisters = [{ usage: 1, every: 1, start: new Date().toISOString() }],
|
blisters = [{ usage: 1, every: 1, start: new Date().toISOString() }],
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
@@ -141,8 +147,8 @@ export async function createTestMedication(
|
|||||||
sql: `INSERT INTO medications (
|
sql: `INSERT INTO medications (
|
||||||
user_id, name, generic_name, taken_by_json,
|
user_id, name, generic_name, taken_by_json,
|
||||||
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
|
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
|
||||||
pill_weight_mg, usage_json, every_json, start_json
|
pill_weight_mg, usage_json, every_json, start_json, expiry_date, notes, intake_reminders_enabled
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
|
||||||
args: [
|
args: [
|
||||||
userId,
|
userId,
|
||||||
name,
|
name,
|
||||||
@@ -156,6 +162,9 @@ export async function createTestMedication(
|
|||||||
usageJson,
|
usageJson,
|
||||||
everyJson,
|
everyJson,
|
||||||
startJson,
|
startJson,
|
||||||
|
expiryDate,
|
||||||
|
notes,
|
||||||
|
intakeRemindersEnabled ? 1 : 0,
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-frontend",
|
"name": "medassist-ng-frontend",
|
||||||
"version": "1.0.2",
|
"version": "1.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "medassist-ng-frontend",
|
"name": "medassist-ng-frontend",
|
||||||
"version": "1.0.2",
|
"version": "1.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"i18next": "^24.2.2",
|
"i18next": "^24.2.2",
|
||||||
"i18next-browser-languagedetector": "^8.0.4",
|
"i18next-browser-languagedetector": "^8.0.4",
|
||||||
|
|||||||
@@ -351,6 +351,12 @@ function AppContent() {
|
|||||||
const [shareGenerating, setShareGenerating] = useState(false);
|
const [shareGenerating, setShareGenerating] = useState(false);
|
||||||
const [shareLink, setShareLink] = useState<string | null>(null);
|
const [shareLink, setShareLink] = useState<string | null>(null);
|
||||||
const [shareCopied, setShareCopied] = useState(false);
|
const [shareCopied, setShareCopied] = useState(false);
|
||||||
|
// Export/Import state
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
|
||||||
|
const [showImportConfirm, setShowImportConfirm] = useState(false);
|
||||||
|
const [pendingImportData, setPendingImportData] = useState<any>(null);
|
||||||
// Collapsed days state (manually collapsed days are persisted)
|
// Collapsed days state (manually collapsed days are persisted)
|
||||||
const [manuallyCollapsedDays, setManuallyCollapsedDays] = useState<Set<string>>(new Set());
|
const [manuallyCollapsedDays, setManuallyCollapsedDays] = useState<Set<string>>(new Set());
|
||||||
const [manuallyExpandedDays, setManuallyExpandedDays] = useState<Set<string>>(new Set());
|
const [manuallyExpandedDays, setManuallyExpandedDays] = useState<Set<string>>(new Set());
|
||||||
@@ -778,6 +784,110 @@ function AppContent() {
|
|||||||
setSendingReminderEmail(false);
|
setSendingReminderEmail(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Export data to JSON file
|
||||||
|
async function handleExport() {
|
||||||
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/export?includeSensitive=true', {
|
||||||
|
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<HTMLInputElement>) {
|
||||||
|
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) {
|
async function deleteMed(id: number) {
|
||||||
await fetch(`/api/medications/${id}`, { method: "DELETE" }).catch(() => null);
|
await fetch(`/api/medications/${id}`, { method: "DELETE" }).catch(() => null);
|
||||||
if (editingId === id) resetForm();
|
if (editingId === id) resetForm();
|
||||||
@@ -2299,6 +2409,48 @@ function AppContent() {
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
{/* Export/Import Section */}
|
||||||
|
<article className="card">
|
||||||
|
<div className="card-head">
|
||||||
|
<h2>
|
||||||
|
{t('exportImport.title')}
|
||||||
|
<span className="info-tooltip" data-tooltip={t('exportImport.description')}>ⓘ</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="setting-section">
|
||||||
|
<div className="export-import-grid">
|
||||||
|
{/* Export */}
|
||||||
|
<div className="export-import-card">
|
||||||
|
<h3>{t('exportImport.exportTitle')}</h3>
|
||||||
|
<p className="export-import-desc">{t('exportImport.exportDesc')}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={exporting}
|
||||||
|
>
|
||||||
|
{exporting ? t('exportImport.exporting') : t('exportImport.export')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Import */}
|
||||||
|
<div className="export-import-card">
|
||||||
|
<h3>{t('exportImport.importTitle')}</h3>
|
||||||
|
<p className="export-import-desc">{t('exportImport.importDesc')}</p>
|
||||||
|
<label className="export-import-file-btn">
|
||||||
|
{importing ? t('exportImport.importing') : t('exportImport.import')}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".json,application/json"
|
||||||
|
onChange={handleImportFileSelect}
|
||||||
|
disabled={importing}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
<div className="form-footer">
|
<div className="form-footer">
|
||||||
<button type="submit" disabled={settingsSaving || (!settingsChanged && settingsSaved)}>
|
<button type="submit" disabled={settingsSaving || (!settingsChanged && settingsSaved)}>
|
||||||
{settingsSaving ? t('common.saving') : settingsSaved && !settingsChanged ? t('common.saved') : t('settings.saveSettings')}
|
{settingsSaving ? t('common.saving') : settingsSaved && !settingsChanged ? t('common.saved') : t('settings.saveSettings')}
|
||||||
@@ -2306,6 +2458,39 @@ function AppContent() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Import Confirmation Modal */}
|
||||||
|
{showImportConfirm && (
|
||||||
|
<div className="modal-overlay" onClick={() => setShowImportConfirm(false)}>
|
||||||
|
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{maxWidth: "450px"}}>
|
||||||
|
<button className="modal-close" onClick={() => { setShowImportConfirm(false); setPendingImportData(null); }}>×</button>
|
||||||
|
<h2 style={{marginBottom: "16px", paddingRight: "2rem"}}>{t('exportImport.confirmImport')}</h2>
|
||||||
|
<p style={{marginBottom: "12px"}}>{t('exportImport.confirmImportMessage')}</p>
|
||||||
|
<p className="warning-text" style={{marginBottom: "24px"}}>
|
||||||
|
⚠️ {t('exportImport.confirmImportWarning')}
|
||||||
|
</p>
|
||||||
|
<div className="modal-footer" style={{padding: "1rem 0 0 0", borderTop: "none", justifyContent: "flex-end"}}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setShowImportConfirm(false);
|
||||||
|
setPendingImportData(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('exportImport.cancelButton')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="danger"
|
||||||
|
onClick={handleImportConfirm}
|
||||||
|
>
|
||||||
|
{t('exportImport.confirmButton')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
|
|||||||
@@ -348,5 +348,31 @@
|
|||||||
"contact": "Bitte kontaktiere {{username}} um einen neuen Link anzufordern.",
|
"contact": "Bitte kontaktiere {{username}} um einen neuen Link anzufordern.",
|
||||||
"expiredOn": "Abgelaufen am: {{date}}"
|
"expiredOn": "Abgelaufen am: {{date}}"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"exportImport": {
|
||||||
|
"title": "Daten Export / Import",
|
||||||
|
"description": "Sichere deine Daten oder übertrage sie auf ein anderes Gerät.",
|
||||||
|
"exportTitle": "Export",
|
||||||
|
"exportDesc": "Lade alle deine Daten als JSON-Datei herunter.",
|
||||||
|
"importTitle": "Import",
|
||||||
|
"importDesc": "Stelle Daten aus einer Sicherung wieder her. Dies ersetzt alle bestehenden Daten.",
|
||||||
|
"export": "Daten exportieren",
|
||||||
|
"exporting": "Exportiere...",
|
||||||
|
"import": "Datei auswählen",
|
||||||
|
"importing": "Importiere...",
|
||||||
|
"selectFile": "Datei auswählen",
|
||||||
|
"includeSensitive": "Sensible Daten einschließen (Benachrichtigungs-URLs)",
|
||||||
|
"sensitiveWarning": "Benachrichtigungs-URLs können Passwörter enthalten und werden im Klartext gespeichert.",
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -350,5 +350,31 @@
|
|||||||
"contact": "Please contact {{username}} to request a new link.",
|
"contact": "Please contact {{username}} to request a new link.",
|
||||||
"expiredOn": "Expired on: {{date}}"
|
"expiredOn": "Expired on: {{date}}"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"exportImport": {
|
||||||
|
"title": "Data Export / Import",
|
||||||
|
"description": "Backup your data or transfer it to another device.",
|
||||||
|
"exportTitle": "Export",
|
||||||
|
"exportDesc": "Download all your data as a JSON file.",
|
||||||
|
"importTitle": "Import",
|
||||||
|
"importDesc": "Restore data from a backup file. This will replace all existing data.",
|
||||||
|
"export": "Export Data",
|
||||||
|
"exporting": "Exporting...",
|
||||||
|
"import": "Select File",
|
||||||
|
"importing": "Importing...",
|
||||||
|
"selectFile": "Select File",
|
||||||
|
"includeSensitive": "Include sensitive data (notification URLs)",
|
||||||
|
"sensitiveWarning": "Notification URLs may contain passwords and will be stored in plain text.",
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3977,3 +3977,79 @@ h3 .reminder-icon.info-tooltip {
|
|||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Export/Import Section */
|
||||||
|
.card:has(.export-import-grid) {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:has(.export-import-grid) .card-head {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-import-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.export-import-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-import-card {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-secondary);
|
||||||
|
border-radius: var(--card-radius);
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-import-card h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-import-desc {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-import-card button,
|
||||||
|
.export-import-file-btn {
|
||||||
|
margin-top: auto;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-import-file-btn {
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.7rem 1.25rem;
|
||||||
|
border-radius: var(--btn-radius);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-import-file-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-import-file-btn input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user