diff --git a/.env.example b/.env.example
index 68c8b4d..7570db7 100644
--- a/.env.example
+++ b/.env.example
@@ -10,37 +10,37 @@ PUID=1000
PGID=1000
PORT=3000
+# Docker Compose quickstart serves the frontend on http://localhost:4174.
+# Local Vite development usually uses http://localhost:5173 or http://localhost:4173 instead.
CORS_ORIGINS=http://localhost:4174
-LOG_LEVEL=warn
-# Levels: debug, info, warn, error, silent
-# Controls: backend Fastify logging, frontend nginx access logs (Docker),
-# and frontend browser console (via build-time injection)
-#
-# Behavior per level:
-# debug — all app logs + all HTTP request logs (including polling endpoints)
-# info — all app logs + HTTP request logs, EXCEPT high-frequency polling
-# (GET /doses/taken, GET /share/:token/doses, GET /health are hidden)
-# warn — only warnings and errors
-# error — only errors
-# silent — no logs
+# Server default timezone for scheduled reminders.
+# Users can override this in Settings -> Timezone.
+TZ=Europe/Berlin
+
+# Public base URL used for notification action links.
+# Required for intake reminder action buttons.
+# Use an externally reachable HTTPS URL for remote/self-hosted access.
+# PUBLIC_APP_URL=https://medassist.example.com
+# If this uses a non-local host, include that origin in CORS_ORIGINS.
+# Local Vite development automatically allows this hostname; set
+# VITE_ALLOWED_HOSTS only when you need additional development hostnames.
+
+# Log level: debug, info, warn, error, silent
+LOG_LEVEL=info
# Rate limit: max requests per minute per IP (default: 100)
# Increase for development/testing environments
# RATE_LIMIT_MAX=100
# API documentation UI + OpenAPI JSON
-# Default behavior: enabled outside production, disabled in production
-# When enabled, docs are available on /docs and /docs/json.
+# Docs are served on /docs and /docs/json.
+# Default behavior: enabled outside production, disabled in production.
# Recommended:
-# development/staging: OPENAPI_DOCS_ENABLED=true
-# production: leave unset, or set OPENAPI_DOCS_ENABLED=false
+# development, staging: OPENAPI_DOCS_ENABLED=true
+# production: leave unset or set OPENAPI_DOCS_ENABLED=false
# OPENAPI_DOCS_ENABLED=true
-# Server default timezone for scheduled reminders (e.g., Europe/Berlin, America/New_York).
-# Users can override this per account in Settings -> Timezone.
-TZ=Europe/Berlin
-
# =============================================================================
# Authentication (optional - disabled by default for easy setup)
# =============================================================================
@@ -125,7 +125,7 @@ EXPIRY_WARNING_DAYS=30 # Days before expiry to show yellow warning
# DEFAULT_EMAIL_INTAKE_REMINDERS=true
# DEFAULT_EMAIL_PRESCRIPTION_REMINDERS=true
-# Push notifications (ntfy/gotify via Shoutrrr)
+# Push notifications (Shoutrrr URL)
# DEFAULT_SHOUTRRR_ENABLED=false
# DEFAULT_SHOUTRRR_URL=
# DEFAULT_SHOUTRRR_STOCK_REMINDERS=true
@@ -149,6 +149,6 @@ EXPIRY_WARNING_DAYS=30 # Days before expiry to show yellow warning
# UI defaults
# DEFAULT_LANGUAGE=en # en or de
# DEFAULT_STOCK_CALCULATION_MODE=automatic # automatic or manual
-# DEFAULT_SHARE_STOCK_STATUS=true # Show stock status on shared schedule links
+# DEFAULT_SHARE_MEDICATION_OVERVIEW=false # Show medication overview on shared schedule links
# DEFAULT_UPCOMING_TODAY_ONLY=false
-# DEFAULT_SHARE_SCHEDULE_TODAY_ONLY=false
\ No newline at end of file
+# DEFAULT_SHARE_SCHEDULE_TODAY_ONLY=false
diff --git a/.github/agents/release-manager.agent.md b/.github/agents/release-manager.agent.md
index fe48c3d..b50d905 100644
--- a/.github/agents/release-manager.agent.md
+++ b/.github/agents/release-manager.agent.md
@@ -245,29 +245,10 @@ Apply these rules strictly:
## Task 3: Execute Release
-Use the release script — it is **fully non-interactive** (no y/N prompts) and handles the entire flow automatically:
-
-```bash
-./scripts/release.sh
-```
-
-The script performs these steps in order:
-1. Checks out and updates `main`
-2. Creates release branch `chore/release-X.Y.Z`
-3. Bumps version in `backend/package.json` and `frontend/package.json`
-4. Commits, pushes, and creates a PR
-5. Waits for CI checks (with retry logic — polls every 15s, waits up to 10 minutes)
-6. Merges the PR (squash + delete branch)
-7. Creates a signed tag `vX.Y.Z` and pushes it
+Use the manual release flow. The repository no longer uses a public release helper script.
**Release precondition:** never start the release flow from a dirty or stale mixed workspace. If the repository root contains unrelated/stale diffs, first switch to a clean base that matches the authoritative remote main.
-**The script auto-detects the git remote** (`origin` or `github`) and uses it consistently.
-
-**CI wait behavior:** GitHub Actions can take 10-30 seconds before checks appear on a new PR. The script waits 20 seconds initially, then polls every 15 seconds until checks are registered, then watches them to completion. Maximum wait is 10 minutes.
-
-**On failure:** If CI fails, the script exits with an error. The release branch and PR remain open for inspection. Fix the issue, push to the branch, and the PR will re-run CI. Then merge manually or re-run the script.
-
### Version Files (MANDATORY)
The version number is displayed in the **About modal** (Settings → About) as a single unified app version. This version is a **clickable link** pointing to the corresponding GitHub release (`https://github.com/DanielVolz/medassist-ng/releases/tag/vX.Y.Z`). The version is read from:
@@ -279,7 +260,7 @@ The version number is displayed in the **About modal** (Settings → About) as a
- The About modal will show the old version
- The version link will point to a non-existent GitHub release page
-### Manual Release (if script is not available)
+### Manual Release
1. Create release branch:
```bash
@@ -523,8 +504,7 @@ Ready for release?
7. Check current version (git tag + package.json)
8. Analyze changes → determine SemVer level
9. If minor/major: check README.md for needed updates (Task 5)
-10. Run ./scripts/release.sh
- (or manually: branch → version bump → PR → CI → merge → tag)
+10. Run the manual release flow: branch → version bump → PR → CI → merge → tag
↓
11. Write release notes (mandatory for minor/major)
12. Publish GitHub release
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 0824a9d..6bc23cc 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -1,19 +1,13 @@
# MedAssist-ng - Copilot Entry Point
-## VERY IMPORTANT
+This file is intentionally thin. `AGENTS.md` is the canonical governance file for this repository.
-- Always keep agent work memory updated in `doku/memory_notes.md` so progress and decisions remain recoverable across context loss.
-- Always keep a user-facing work report updated in `doku/report.md` so completed work is easy to review.
-- This memory/report rule replaces the previous `doku/APP_BEHAVIOR.md` persistence requirement.
-
-Use `AGENTS.md` as the single source of truth for all governance, workflow, and skill rules.
+If rules differ between files, follow `AGENTS.md`.
## Required Startup Steps
-1. Read `AGENTS.md` first.
-2. Identify triggered skills from `AGENTS.md` and read each referenced `SKILL.md` before making changes.
-3. Follow delegation boundaries exactly (`@testing-manager` for testing, `@release-manager` for release orchestration).
-
-## Scope
-
-This file intentionally stays minimal to prevent duplicated or conflicting instructions.
+1. Read `AGENTS.md` first when it exists in the workspace.
+2. Ensure `doku/memory_notes.md` and `doku/report.md` exist and keep them updated during meaningful work. These files are local-only and must not be staged or committed unless explicitly requested.
+3. Identify triggered skills from `AGENTS.md` and read only the matching `SKILL.md` files before making changes.
+4. Follow delegation boundaries from `AGENTS.md`: `@testing-manager` for testing work and `@release-manager` for release orchestration, including the documented fallback protocol when a required specialist is unavailable.
+5. Keep all non-canonical instruction files brief and aligned with `AGENTS.md`; do not duplicate full governance here.
diff --git a/.github/workflows/add-to-project.yml b/.github/workflows/add-to-project.yml
index 573b9f1..3aaa375 100644
--- a/.github/workflows/add-to-project.yml
+++ b/.github/workflows/add-to-project.yml
@@ -11,7 +11,7 @@ jobs:
name: Add issue to project
runs-on: ubuntu-latest
steps:
- - uses: actions/add-to-project@v1.0.2
+ - uses: actions/add-to-project@v2.0.0
with:
project-url: ${{ vars.PROJECT_URL }}
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml
index 2f741ab..cd0af90 100644
--- a/.github/workflows/docker-build.yml
+++ b/.github/workflows/docker-build.yml
@@ -24,6 +24,8 @@ on:
concurrency:
group: docker-build-${{ github.ref }}
+ # Cancel older runs on the same ref so the shared branch tag stays aligned
+ # with the newest commit instead of racing older builds against newer ones.
cancel-in-progress: true
# Default minimal permissions
diff --git a/.gitignore b/.gitignore
index 42b31b4..0658812 100644
--- a/.gitignore
+++ b/.gitignore
@@ -67,12 +67,12 @@ Thumbs.db
.idea/
*.sublime-project
*.sublime-workspace
+/.vscode/settings.json
-# Keep shared VS Code settings
-# .vscode/ is NOT ignored - settings.json is useful for the team
+# Keep shared VS Code workspace files, but ignore personal editor settings.
# ===================
-# Misc
+# Local-only workspace artifacts (never upstream)
# ===================
*.local
.cache/
@@ -82,12 +82,9 @@ Thumbs.db
.claude/
AGENTS.md
docs/TECH_STACK.md
-doku/
-
-# Local agent work logs stay on disk but must never go upstream.
-doku/memory_notes.md
-doku/report.md
-plan/
+/doku/
+/plan/
+/.planning/
.copilot-tracking/
.playwright-cli/
.agents/
@@ -107,4 +104,15 @@ docs/SPEC_KIT.md
.github/skills/nodejs-backend-patterns/
.github/skills/nodejs-best-practices/
.github/skills/seo/
-.playwright-mcp
\ No newline at end of file
+.playwright-mcp
+
+# Local GSD/copilot generated workspace artifacts (not for upstream)
+.github/agents/copilot-instructions.md
+.github/agents/gsd-*.agent.md
+.github/agents/medassist-feature-orchestrator.agent.md
+.github/agents/speckit.*.agent.md
+.github/get-shit-done/
+.github/gsd-file-manifest.json
+.github/prompts/speckit.*.prompt.md
+.github/skills/gsd-*/
+ops/medtest/
\ No newline at end of file
diff --git a/.planning/codebase/ARCHITECTURE.md b/.planning/codebase/ARCHITECTURE.md
new file mode 100644
index 0000000..f949144
--- /dev/null
+++ b/.planning/codebase/ARCHITECTURE.md
@@ -0,0 +1,168 @@
+
+# Architecture
+
+**Analysis Date:** 2026-04-30
+
+## System Overview
+
+```text
+┌─────────────────────────────────────────────────────────────┐
+│ Frontend SPA (React) │
+├──────────────────┬──────────────────┬───────────────────────┤
+│ App Shell/Routes │ Shared State │ Feature Pages │
+│ `frontend/src/ │ `frontend/src/ │ `frontend/src/pages/` │
+│ App.tsx` │ context/` │ │
+└────────┬─────────┴────────┬─────────┴──────────┬────────────┘
+ │ │ │
+ ▼ ▼ ▼
+┌─────────────────────────────────────────────────────────────┐
+│ Backend API (Fastify) │
+│ `backend/src/index.ts` + `backend/src/routes/` │
+└─────────────────────────────────────────────────────────────┘
+ │
+ ▼
+┌─────────────────────────────────────────────────────────────┐
+│ SQLite Persistence + Migration Layer │
+│ `backend/src/db/schema.ts` + `backend/src/db/client.ts` │
+└─────────────────────────────────────────────────────────────┘
+```
+
+## Component Responsibilities
+
+| Component | Responsibility | File |
+|-----------|----------------|------|
+| Frontend bootstrap | Mount providers/router and start app tree | `frontend/src/main.tsx` |
+| App router/shell | Public share routes, authenticated shell routes, global modal composition | `frontend/src/App.tsx` |
+| Frontend orchestration | Compose domain hooks and expose app-level state/actions | `frontend/src/context/AppContext.tsx` |
+| API proxy boundary | Rewrite `/api/*` requests to backend root routes | `frontend/vite.config.ts` |
+| Backend composition root | Register plugins/routes, await migrations, start schedulers | `backend/src/index.ts` |
+| Route handlers | HTTP contracts, validation, auth hooks, response shaping | `backend/src/routes/*.ts` |
+| Domain services | Shared domain logic and scheduler behavior | `backend/src/services/*.ts` |
+| Persistence | Table definitions + compatibility migration/runtime initialization | `backend/src/db/schema.ts`, `backend/src/db/client.ts` |
+
+## Pattern Overview
+
+**Overall:** Layered modular monolith (single frontend SPA + single backend process)
+
+**Key Characteristics:**
+- Frontend uses React Router + context/hook composition (`frontend/src/App.tsx`, `frontend/src/context/AppContext.tsx`).
+- Backend uses route modules with shared service modules (`backend/src/routes/medications.ts`, `backend/src/services/medications-service.ts`).
+- Data persistence is centralized in Drizzle schema + startup migrations (`backend/src/db/schema.ts`, `backend/src/db/client.ts`).
+
+## Layers
+
+**Frontend Presentation + Orchestration:**
+- Purpose: Render UI, route navigation, manage client state, invoke API.
+- Location: `frontend/src/main.tsx`, `frontend/src/App.tsx`, `frontend/src/pages/`, `frontend/src/context/`, `frontend/src/hooks/`.
+- Contains: pages, modals, app shell, hook-based API callers.
+- Depends on: backend `/api/*`, i18n, shared frontend utils/types.
+- Used by: browser clients.
+
+**Backend HTTP/API Layer:**
+- Purpose: Expose REST endpoints, authenticate/authorize requests, validate input, map to service/db logic.
+- Location: `backend/src/index.ts`, `backend/src/routes/`, `backend/src/plugins/`.
+- Contains: Fastify app setup, route registration, auth middleware.
+- Depends on: services, db client/schema, env plugin.
+- Used by: frontend SPA and API consumers.
+
+**Domain Services Layer:**
+- Purpose: Reusable business logic for scheduling, notifications, stock math, parsing.
+- Location: `backend/src/services/`, `backend/src/utils/`.
+- Contains: reminder scheduler, notification builders/delivery, medication helpers.
+- Depends on: db models and utilities.
+- Used by: routes and startup process.
+
+**Persistence Layer:**
+- Purpose: Define DB schema and keep existing SQLite instances compatible.
+- Location: `backend/src/db/schema.ts`, `backend/src/db/client.ts`, `backend/drizzle/`.
+- Contains: tables, migration execution, backward-compatible alter migrations.
+- Depends on: Drizzle + libsql client.
+- Used by: routes/services.
+
+## Data Flow
+
+### Primary Request Path
+
+1. Frontend page triggers API call via `/api/*` fetch (`frontend/src/pages/PlannerPage.tsx:307`).
+2. Vite proxy rewrites `/api` prefix to backend route root (`frontend/vite.config.ts:23`, `frontend/vite.config.ts:26`).
+3. Fastify route handles request under `/planner/send-email` with auth + validation (`backend/src/routes/planner.ts:141`, `backend/src/routes/planner.ts:158`).
+4. Route loads user settings and dispatches channel delivery helpers (`backend/src/routes/planner.ts:221`, `backend/src/routes/planner.ts:432`, `backend/src/routes/planner.ts:829`).
+
+### Public Share Flow
+
+1. Frontend routes public token URL to shared schedule view (`frontend/src/App.tsx:35`).
+2. Shared schedule component fetches token payload from `/api/share/:token` (`frontend/src/components/SharedSchedule.tsx:311`).
+3. Backend public share route reads token/settings and returns filtered medication schedule (`backend/src/routes/share.ts:125`, `backend/src/routes/share.ts:156`).
+
+**State Management:**
+- Frontend: context-centric state aggregation (`frontend/src/context/AppContext.tsx:248`, `frontend/src/context/AppContext.tsx:1020`).
+- Backend: DB-backed state with runtime scheduler state persisted through notification state utilities (`backend/src/services/reminder-scheduler.ts:42`).
+
+## Key Abstractions
+
+**Auth Context + Guards:**
+- Purpose: unify session/API-key auth across protected routes.
+- Examples: `backend/src/plugins/auth.ts`, `backend/src/routes/settings.ts`.
+- Pattern: route-level `preHandler` guard plus request decoration (`backend/src/routes/settings.ts:138`, `backend/src/plugins/auth.ts:236`).
+
+**Notification Delivery Contract:**
+- Purpose: keep route-triggered and scheduler-triggered notifications consistent.
+- Examples: `backend/src/routes/planner.ts`, `backend/src/services/reminder-scheduler.ts`, `backend/src/services/notifications/delivery.ts`.
+- Pattern: shared builders/delivery/state helpers imported into both paths (`backend/src/routes/planner.ts:23`, `backend/src/services/reminder-scheduler.ts:39`).
+
+**Frontend App Context Aggregator:**
+- Purpose: centralize shared medication/settings/dose/share/refill state for page/modal consumers.
+- Examples: `frontend/src/context/AppContext.tsx`, `frontend/src/context/ShareContext.tsx`.
+- Pattern: compose domain hooks, expose typed value via provider (`frontend/src/context/AppContext.tsx:248`, `frontend/src/context/AppContext.tsx:1020`).
+
+## Entry Points
+
+**Frontend bootstrap:**
+- Location: `frontend/src/main.tsx`
+- Triggers: browser loads `index.html`.
+- Responsibilities: initialize theme/provider stack and router (`frontend/src/main.tsx:12`, `frontend/src/main.tsx:15`).
+
+**Backend process entry:**
+- Location: `backend/src/index.ts`
+- Triggers: `npm run dev`/`npm start` in backend package.
+- Responsibilities: await migrations, register routes, start HTTP listener and schedulers (`backend/src/index.ts:231`, `backend/src/index.ts:305`, `backend/src/index.ts:309`, `backend/src/index.ts:334`).
+
+## Architectural Constraints
+
+- **Threading:** Single Node.js event loop process with in-process schedulers started at runtime (`backend/src/index.ts:309`, `backend/src/index.ts:323`).
+- **Global state:** Module/global singletons exist in auth and context layers (`backend/src/plugins/auth.ts:15`, `frontend/src/context/AppContext.tsx:222`).
+- **Circular imports:** Not detected from sampled route/service/db/frontend orchestration files.
+- **API boundary:** Frontend network calls must use `/api/*` so proxy rewrite applies (`frontend/vite.config.ts:23`, `frontend/vite.config.ts:26`).
+
+## Anti-Patterns
+
+### Duplicated Backend App Wiring
+
+**What happens:** Route/plugin registration appears in both `createApp(...)` and top-level startup path.
+**Why it's wrong:** Two bootstrap paths increase divergence risk when new routes/plugins are added in one path but not the other.
+**Do this instead:** Keep a single shared app-construction function used by both test/runtime startup paths (`backend/src/index.ts:133`, `backend/src/index.ts:207`, `backend/src/index.ts:289`).
+
+### Oversized Frontend Orchestration Context
+
+**What happens:** `AppContext` aggregates many unrelated concerns (medications, settings, doses, sharing, import/export, modal history) in one large provider.
+**Why it's wrong:** High coupling and broad rerender surface make safe changes harder and increase regression risk.
+**Do this instead:** Preserve existing provider contract, but move new domain concerns into focused hooks/providers and re-export through composition only when needed (`frontend/src/context/AppContext.tsx`, file size ~1035 lines).
+
+## Error Handling
+
+**Strategy:** Fail fast at route boundary with explicit status codes and schema validation, then log context-rich errors.
+
+**Patterns:**
+- Route validation + immediate 400 responses for invalid input (`backend/src/routes/medications.ts:76`, `backend/src/routes/medications.ts:584`).
+- Planner routes return explicit channel/config errors (`backend/src/routes/planner.ts:204`, `backend/src/routes/planner.ts:509`).
+- Frontend captures network errors and maps them to normalized error codes for UI handling (`frontend/src/hooks/useMedications.ts:80`).
+
+## Cross-Cutting Concerns
+
+**Logging:** Fastify logger options configured centrally with environment-aware formatting (`backend/src/index.ts:66`, `backend/src/index.ts:161`).
+**Validation:** Zod validation for medication payloads and explicit OpenAPI schema contracts in routes (`backend/src/routes/medications.ts:76`, `backend/src/routes/planner.ts:157`).
+**Authentication:** Route-level auth hooks and dual API-key/session handling (`backend/src/routes/planner.ts:141`, `backend/src/plugins/auth.ts:113`, `backend/src/plugins/auth.ts:236`).
+
+---
+
+*Architecture analysis: 2026-04-30*
diff --git a/.planning/codebase/CONCERNS.md b/.planning/codebase/CONCERNS.md
new file mode 100644
index 0000000..a71171a
--- /dev/null
+++ b/.planning/codebase/CONCERNS.md
@@ -0,0 +1,122 @@
+# Codebase Concerns
+
+**Analysis Date:** 2026-04-30
+
+## Tech Debt
+
+**Backend startup duplication and config drift:**
+- Issue: `backend/src/index.ts` contains two parallel server setup paths (the exported `createApp(...)` flow and the top-level runtime bootstrap). Plugin/route registration and rate-limit defaults are duplicated in both branches.
+- Files: `backend/src/index.ts`
+- Impact: Configuration behavior can diverge between test/programmatic app construction and production startup (for example, `createApp` uses fixed `rateLimit` max `300`, while runtime startup uses `process.env.RATE_LIMIT_MAX` fallback `100`).
+- Fix approach: Extract one canonical app-construction function and let both runtime startup and tests consume it; remove duplicated registration blocks.
+
+**Notification architecture leakage and duplicated composition logic:**
+- Issue: Notification delivery service code imports a route-layer helper (`sendShoutrrrNotification`) from settings routes, and large HTML/text reminder composition blocks are duplicated across manual and automatic reminder paths.
+- Files: `backend/src/services/notifications/delivery.ts`, `backend/src/routes/settings.ts`, `backend/src/routes/planner.ts`, `backend/src/services/reminder-scheduler.ts`
+- Impact: Layer boundary violations increase coupling, and duplicated notification formatting logic makes behavior regressions likely when changing message content or channel behavior.
+- Fix approach: Move `sendShoutrrrNotification` to a service-layer module, make routes call service APIs only, and centralize email/push payload builders for planner + scheduler flows.
+
+**Migration artifact ambiguity in drizzle numbering:**
+- Issue: There are two migration files with `0008_` prefix, but the journal tracks only one `0008` tag and then jumps to `0009`.
+- Files: `backend/drizzle/0008_add_obsolete_medications.sql`, `backend/drizzle/0008_add_prescription_tracking.sql`, `backend/drizzle/meta/_journal.json`
+- Impact: Developer confusion and higher risk of migration-order mistakes during future schema changes.
+- Fix approach: Align migration file names and journal tags so each migration number is unique and journal order is obvious.
+
+**Monolithic UI/editor and route modules with broad lint suppressions:**
+- Issue: Core interaction files are very large and rely on file-level `biome-ignore-all` suppressions for multiple rule categories.
+- Files: `frontend/src/pages/MedicationsPage.tsx`, `frontend/src/components/MobileEditModal.tsx`, `frontend/src/components/SharedSchedule.tsx`, `frontend/src/components/MedDetailModal.tsx`, `backend/src/routes/medications.ts`
+- Impact: Refactors become high-risk; local regressions are harder to isolate; suppressed rule categories hide legitimate quality issues in future edits.
+- Fix approach: Split by domain slices (state orchestration vs rendering vs helper transforms), then replace file-level suppressions with narrow, local exceptions only where justified.
+
+## Known Bugs
+
+**Environment-dependent behavior mismatch between test app factory and runtime app:**
+- Symptoms: Programmatic app creation and runtime startup can apply different operational defaults (rate limiting and selected config pathways).
+- Files: `backend/src/index.ts`
+- Trigger: Using `createApp(...)` in tests/integration contexts while production startup uses the top-level runtime branch.
+- Workaround: Explicitly pass runtime-equivalent options into `createApp(...)` in tests until startup construction is unified.
+
+## Security Considerations
+
+**Server-side outbound notification surface is broad and sensitive to parser correctness:**
+- Risk: The app performs server-side HTTP requests to user-configurable notification URLs, including multiple protocol handlers (`pushover://`, `telegram://`, `gotify://`, generic webhook URLs).
+- Files: `backend/src/routes/settings.ts`
+- Current mitigation: URL sanitation/validation and hostname checks are present (`sanitizeNotificationUrl`, `validateNotificationHostname` usage in route logic).
+- Recommendations: Add focused security regression tests for sanitizer bypasses and callback URL edge cases, and keep all outbound request execution in a dedicated service layer.
+
+**Auth-off bootstrap path creates implicit default user state:**
+- Risk: In auth-disabled mode, startup creates/relies on a default user path automatically.
+- Files: `backend/src/db/client.ts`
+- Current mitigation: Controlled by `AUTH_ENABLED` environment setting.
+- Recommendations: Add startup log warnings when running without auth outside development and enforce explicit environment confirmation in deployment templates.
+
+## Performance Bottlenecks
+
+**Reminder scheduling uses repeated full scans over users and medication/dose datasets:**
+- Problem: Reminder checks iterate all user settings and compute stock/prescription reminders with repeated in-memory loops over medication and dose collections.
+- Files: `backend/src/services/reminder-scheduler.ts`, `backend/src/utils/scheduler-utils.ts`
+- Cause: Polling/check strategy prioritizes correctness and compatibility over incremental indexing.
+- Improvement path: Introduce incremental candidate selection (changed-medication windows, per-user next-check indices) and reduce repeated whole-set scans.
+
+**Intake reminder scheduler polls every minute and may scale linearly with active schedules:**
+- Problem: Intake reminder check loop runs continuously at 60s interval and processes all due reminders/users each tick.
+- Files: `backend/src/services/intake-reminder-scheduler.ts`
+- Cause: Fixed-interval scheduler (`CHECK_INTERVAL_MS = 60 * 1000`) with loop-driven due-item selection.
+- Improvement path: Move toward next-due-time scheduling or bucketing strategy; keep minute polling as fallback only.
+
+## Fragile Areas
+
+**Reminder state persistence and lock handling mix sync file IO with best-effort catches:**
+- Files: `backend/src/services/notifications/state.ts`, `backend/src/services/reminder-scheduler.ts`
+- Why fragile: Reminder state writes are synchronous file writes and some read paths swallow errors (`catch {}`), while lock/state files are local filesystem coordination primitives.
+- Safe modification: Keep file format backward-compatible, add explicit error telemetry, and add tests for concurrent/failed write scenarios before changing scheduler state logic.
+- Test coverage: No direct tests detected for `notifications/delivery.ts` and only limited direct state-function assertions.
+
+**Desktop/mobile medication edit parity depends on two large independent UI paths:**
+- Files: `frontend/src/pages/MedicationsPage.tsx`, `frontend/src/components/MobileEditModal.tsx`, `frontend/src/components/medications/MedicationEditCoordinator.tsx`
+- Why fragile: The same editing domain is implemented in separate surfaces, each with dense UI logic and custom interaction handling.
+- Safe modification: Apply shared form-section components first, then update desktop and mobile in the same change; validate both paths with targeted tests.
+- Test coverage: Coverage exists (`MedicationEditCoordinator`, `MobileEditModal`, `MedicationDialogs` tests), but parity regressions remain a recurring risk due to file size/complexity.
+
+## Scaling Limits
+
+**Current reminder architecture is single-node/local-state oriented:**
+- Current capacity: Scheduler state and lock coordination are local files under data directory (`reminder-state.json`, `scheduler-locks/*`).
+- Limit: Horizontal multi-instance scaling can duplicate work or require externalized coordination.
+- Scaling path: Move reminder state/locks to DB or distributed lock backend and make scheduler execution leader-aware.
+
+**SQLite file-backed persistence constrains concurrent write scaling:**
+- Current capacity: Single SQLite file with local filesystem path resolution.
+- Limit: Higher write concurrency and distributed deployments will hit filesystem/database locking and throughput limits.
+- Scaling path: Keep SQLite for local/small deployments; define migration path to managed DB for larger multi-user workloads.
+
+## Dependencies at Risk
+
+**Route-to-service coupling in notification stack:**
+- Risk: Service-layer delivery module depends on route-layer helper import.
+- Impact: Refactors of route modules can break unrelated notification infrastructure and complicate testing boundaries.
+- Migration plan: Move shared notification send helpers into `backend/src/services/notifications/*` and keep route modules thin.
+
+## Missing Critical Features
+
+**Risk-driven scheduler stress/integration test suite for state-lock edge cases:**
+- Problem: Complex scheduler/state code paths rely on file coordination and mixed channel delivery outcomes, but dedicated stress/chaos-style verification is limited.
+- Blocks: High-confidence scaling and reliability changes in reminder subsystems.
+
+## Test Coverage Gaps
+
+**Notification delivery abstraction lacks direct unit tests:**
+- What's not tested: Direct behavior of SMTP transport creation/result validation and push delivery helpers in the dedicated delivery module.
+- Files: `backend/src/services/notifications/delivery.ts`
+- Risk: Regressions in recipient validation, SMTP response handling, or provider fallback can ship unnoticed.
+- Priority: High
+
+**Reminder state persistence/locking has limited direct verification:**
+- What's not tested: Corrupted file recovery, concurrent state writes, and lock stale-file behavior under failure modes.
+- Files: `backend/src/services/notifications/state.ts`, `backend/src/services/reminder-scheduler.ts`
+- Risk: Duplicate sends or missed sends after crashes/restarts are difficult to detect early.
+- Priority: High
+
+---
+
+*Concerns audit: 2026-04-30*
diff --git a/.planning/codebase/CONVENTIONS.md b/.planning/codebase/CONVENTIONS.md
new file mode 100644
index 0000000..604a990
--- /dev/null
+++ b/.planning/codebase/CONVENTIONS.md
@@ -0,0 +1,116 @@
+# Coding Conventions
+
+**Analysis Date:** 2026-04-30
+
+## Naming Patterns
+
+**Files:**
+- Frontend React components and pages use PascalCase file names (for example `frontend/src/components/MobileEditModal.tsx`, `frontend/src/pages/MedicationsPage.tsx`).
+- Hooks use `useX` camelCase naming in files and symbols (for example `frontend/src/hooks/useMedications.ts`, `frontend/src/hooks/useScheduleController.ts`).
+- Backend routes/services use kebab-case file names with domain suffixes (for example `backend/src/routes/medications.ts`, `backend/src/services/medications-service.ts`).
+- Test files use `*.test.ts` or `*.test.tsx` in dedicated test folders (for example `backend/src/test/planner.test.ts`, `frontend/src/test/components/MobileEditModal.test.tsx`).
+
+**Functions:**
+- Use camelCase names for functions and methods (for example `parseIntakesWithUnits` in `backend/src/services/medications-service.ts`, `loadMeds` in `frontend/src/hooks/useMedications.ts`).
+- Use verb-first names for side-effect operations (`loadMeds`, `deleteMed`, `uploadMedImage` in `frontend/src/hooks/useMedications.ts`).
+
+**Variables:**
+- Use camelCase for local variables and state (`refillHistoryExpanded`, `scheduleDays`, `showFutureDays` in `frontend/src/context/AppContext.tsx`).
+- Constant maps and singleton keys use UPPER_SNAKE_CASE (`LOG_LEVELS` in `backend/src/utils/logger.ts`, `APP_CONTEXT_SINGLETON_KEY` in `frontend/src/context/AppContext.tsx`).
+
+**Types:**
+- Type aliases and interfaces use PascalCase (`AppContextValue` in `frontend/src/context/AppContext.tsx`, `TestContext` in `backend/src/test/setup.ts`).
+- Return-shape interfaces use `UseXReturn` convention for hooks (`UseMedicationsReturn` in `frontend/src/hooks/useMedications.ts`).
+
+## Code Style
+
+**Formatting:**
+- Tool used: Biome (`biome.json`, scripts in `frontend/package.json`, `backend/package.json`, `package.json`).
+- Key settings from `biome.json`:
+ - `indentStyle: tab`
+ - `indentWidth: 2`
+ - `lineWidth: 120`
+ - JavaScript quote style is double quotes, semicolons enabled, trailing commas `es5`.
+
+**Linting:**
+- Tool used: Biome linter (`biome.json`).
+- Key rules enforced/relevant:
+ - `style.useConst: error`
+ - `style.noNestedTernary: warn`
+ - `correctness.noUnusedVariables: warn`
+ - `suspicious.noExplicitAny: warn`
+- Project governance in `AGENTS.md` reinforces readable code, early returns, and no nested ternaries.
+
+## Import Organization
+
+**Order:**
+1. Node built-ins first in backend modules (for example `node:path` in `backend/src/routes/medications.ts`, `node:crypto` in `backend/src/index.ts`).
+2. External packages second (`fastify`, `zod`, `drizzle-orm` in backend; `react`, `@testing-library/*` in frontend).
+3. Internal modules last with relative paths (`../db/client.js`, `../../types`).
+
+**Path Aliases:**
+- Not detected in TypeScript configs (`frontend/tsconfig.json`, `backend/tsconfig.json` do not define `paths`).
+- Relative imports are the standard.
+
+## Error Handling
+
+**Patterns:**
+- Backend validates request data with Zod schemas and `.refine(...)` constraints before route logic (`backend/src/routes/medications.ts`).
+- Backend route tests assert explicit status codes and body shape (`backend/src/test/routes-real.test.ts`, `backend/src/test/planner.test.ts`).
+- Frontend hooks often normalize recoverable API errors into UI-safe states (`frontend/src/hooks/useMedications.ts` converts network failures into `NETWORK_ERROR`).
+- Some frontend fetch flows still use tolerant fallbacks (`catch(() => setMeds([]))` in `frontend/src/hooks/useMedications.ts`), so future changes should prefer explicit user-facing error channels per `AGENTS.md` fail-clear guidance.
+
+## Logging
+
+**Framework:**
+- Backend startup logger wrapper over console with level filtering in `backend/src/utils/logger.ts`.
+- Runtime HTTP logging via Fastify logger options in `backend/src/index.ts` (`buildLoggerOptions`, request correlation IDs).
+- Frontend logging utility mirrors backend level semantics (`frontend/src/utils/logger.ts`).
+
+**Patterns:**
+- Central log-level maps (`LOG_LEVELS`) and `shouldLog` gating are standard in both frontend and backend logger modules.
+- Correlation ID propagation is enforced at request boundaries (`backend/src/index.ts` onRequest hook setting `x-correlation-id`).
+
+## Comments
+
+**When to Comment:**
+- Comments are used for rationale and test setup intent, not line-by-line narration.
+- Typical examples:
+ - Migration/setup intent in `backend/src/test/setup.ts`
+ - E2E stability rationale in `frontend/e2e/fixtures/index.ts`
+ - Timeout/determinism notes in `frontend/vitest.config.ts` and `frontend/playwright.base.config.ts`
+
+**JSDoc/TSDoc:**
+- Used selectively for exported utilities and test helpers (`backend/src/test/setup.ts`, `frontend/e2e/fixtures/index.ts`, `frontend/src/utils/logger.ts`).
+- Not mandatory for every function; concise type annotations plus targeted comments are preferred.
+
+## Function Design
+
+**Size:**
+- Small-to-medium focused functions are common in services/hooks (`parseRawIntakeUnits`, `normalizeDateTime` in `backend/src/services/medications-service.ts`).
+- Larger orchestrator modules exist where domain aggregation is required (`frontend/src/context/AppContext.tsx`).
+
+**Parameters:**
+- Object parameters are used for extensibility in test factories and route payload shapes (`CreateMedicationOptions` in `backend/src/test/setup.ts`).
+- Explicit primitive parameters used for concise helpers (`clickEditMed(page, medName)` in `frontend/e2e/medication-edit.spec.ts`).
+
+**Return Values:**
+- Explicit return types are common on exported functions (`Promise`, `UseMedicationsReturn`).
+- Guard-clause returns are common for invalid input or unavailable state (`if (!intakesJson) return [];` in `backend/src/services/medications-service.ts`).
+
+## Module Design
+
+**Exports:**
+- Named exports are preferred for utilities, hooks, and service functions (`backend/src/services/notifications/index.ts`, `frontend/src/hooks/index.ts`).
+- Mixed export style is used where legacy/default exports remain practical (`default` exports in component barrel `frontend/src/components/index.ts`).
+
+**Barrel Files:**
+- Barrel files are actively used for stable import surfaces:
+ - `frontend/src/components/index.ts`
+ - `frontend/src/hooks/index.ts`
+ - `backend/src/services/notifications/index.ts`
+- Practical rule for new code: export domain-level public APIs through local barrels, keep deep internal helpers imported directly.
+
+---
+
+*Convention analysis: 2026-04-30*
diff --git a/.planning/codebase/INTEGRATIONS.md b/.planning/codebase/INTEGRATIONS.md
new file mode 100644
index 0000000..d911db0
--- /dev/null
+++ b/.planning/codebase/INTEGRATIONS.md
@@ -0,0 +1,111 @@
+# External Integrations
+
+**Analysis Date:** 2026-04-30
+
+## APIs & External Services
+
+**Medication Data APIs:**
+- European Medicines Agency (EMA) JSON catalog - medication lookup seed and periodic catalog refresh
+ - SDK/Client: native `fetch` in `backend/src/services/medication-enrichment.ts` (`EMA_MEDICINES_URL`)
+ - Auth: none detected in code
+- RxNorm (NLM RxNav REST) - normalized name/search enrichment and strength/form hints
+ - SDK/Client: native `fetch` in `backend/src/services/medication-enrichment.ts` (`RXNORM_BASE_URL`)
+ - Auth: none detected in code
+- openFDA NDC API - product/package metadata enrichment
+ - SDK/Client: native `fetch` in `backend/src/services/medication-enrichment.ts` (`OPENFDA_NDC_URL`)
+ - Auth: none detected in code
+
+**Authentication/Identity Provider Integration:**
+- OIDC providers (Authelia, Authentik, Pocket ID, Keycloak documented) - SSO login/callback flow
+ - SDK/Client: `openid-client` used in `backend/src/routes/oidc.ts`
+ - Auth: `OIDC_ISSUER_URL`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_REDIRECT_URI` validated in `backend/src/plugins/env.ts`
+
+**Messaging/Notifications:**
+- SMTP providers - transactional reminder/test emails
+ - SDK/Client: `nodemailer` in `backend/src/services/notifications/delivery.ts`
+ - Auth: `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS` or `SMTP_TOKEN`, `SMTP_FROM`, `SMTP_SECURE`
+- Push endpoints via Shoutrrr-compatible URL parsing
+ - SDK/Client: native `fetch` in `backend/src/routes/settings.ts` (`sendShoutrrrNotification`)
+ - Auth: URL-embedded creds/token per provider and optional basic auth extracted/sanitized in code
+- Explicit external push provider endpoints used directly:
+ - `https://api.pushover.net/1/messages.json` in `backend/src/routes/settings.ts`
+ - `https://api.telegram.org` in `backend/src/routes/settings.ts`
+
+## Data Storage
+
+**Databases:**
+- SQLite (file-based, local persistent volume)
+ - Connection: `DATA_DIR` (path resolution), optional `DOTENV_PATH` for env source
+ - Client: `@libsql/client` + `drizzle-orm` in `backend/src/db/client.ts`
+- Migration pipeline:
+ - SQL migration artifacts in `backend/drizzle/*.sql`
+ - Runtime migration/alter execution in `backend/src/db/client.ts` and `backend/src/db/migration-utils.ts`
+
+**File Storage:**
+- Local filesystem only
+ - Backend data root resolved by `backend/src/db/path-utils.ts`
+ - Image/static user files served from `/images` in `backend/src/index.ts`
+ - Compose bind mount `./data:/app/data` in `docker-compose.yml`
+
+**Caching:**
+- In-process memory cache only for selected integration data
+ - OIDC discovery config cache in `backend/src/routes/oidc.ts` (`oidcConfig`)
+ - EMA catalog snapshot + refresh promise in `backend/src/services/medication-enrichment.ts`
+- No external cache service detected (no Redis/Memcached dependency in package manifests)
+
+## Authentication & Identity
+
+**Auth Provider:**
+- Custom session/JWT auth with optional OIDC SSO extension
+ - Implementation: Fastify cookie + JWT plugin, refresh token table, API key hashing in `backend/src/plugins/auth.ts`, `backend/src/routes/auth.ts`, `backend/src/plugins/jwt.ts`, `backend/src/routes/oidc.ts`
+
+## Monitoring & Observability
+
+**Error Tracking:**
+- None detected for third-party SaaS error tracking (no Sentry/Rollbar/etc. dependencies)
+
+**Logs:**
+- Structured app logging via Fastify/Pino in `backend/src/index.ts`
+- Pretty logging in dev through `pino-pretty` (`backend/package.json`, logger setup in `backend/src/index.ts`)
+- Frontend/nginx log behavior controlled through env and `frontend/nginx-entrypoint.sh` (documented in `.env.example`)
+
+## CI/CD & Deployment
+
+**Hosting:**
+- Container image publishing to GitHub Container Registry (`ghcr.io`) in `.github/workflows/docker-build.yml`
+- Runtime deployment model is self-hosted Docker Compose stack (`docker-compose.yml`)
+
+**CI Pipeline:**
+- GitHub Actions for lint/type/test (`.github/workflows/test.yml`)
+- Playwright E2E job (`.github/workflows/e2e.yml`)
+- Docker build/push and optional release automation (`.github/workflows/docker-build.yml`)
+
+## Environment Configuration
+
+**Required env vars:**
+- Core runtime: `PORT`, `CORS_ORIGINS`, `LOG_LEVEL`, `TZ` (`backend/src/plugins/env.ts`, `.env.example`)
+- Auth when enabled: `AUTH_ENABLED=true` with `JWT_SECRET`, `REFRESH_SECRET`, `COOKIE_SECRET` (`backend/src/plugins/env.ts`)
+- OIDC when enabled: `OIDC_ENABLED=true` with issuer/client/redirect vars (`backend/src/plugins/env.ts`)
+- Email notifications: `SMTP_HOST`, `SMTP_USER`, plus pass/token and sender config (`backend/src/services/notifications/delivery.ts`, `.env.example`)
+- Data location: `DATA_DIR` used by DB path resolver (`backend/src/db/path-utils.ts`)
+
+**Secrets location:**
+- Local runtime env file `.env` (present in repository root; values not inspected)
+- CI secrets managed by GitHub Actions secret store (e.g., `${{ secrets.GITHUB_TOKEN }}` in `.github/workflows/docker-build.yml`)
+
+## Webhooks & Callbacks
+
+**Incoming:**
+- OIDC callback endpoint: `/auth/oidc/callback` in `backend/src/routes/oidc.ts`
+- No inbound third-party webhook receiver route detected in backend routes
+
+**Outgoing:**
+- Outbound HTTP notifications to webhook-style targets from `sendShoutrrrNotification` in `backend/src/routes/settings.ts`
+- Provider-specific outgoing callbacks/APIs:
+ - Pushover API endpoint
+ - Telegram Bot API endpoint
+- Outbound SMTP delivery through configured mail host (`backend/src/services/notifications/delivery.ts`)
+
+---
+
+*Integration audit: 2026-04-30*
diff --git a/.planning/codebase/STACK.md b/.planning/codebase/STACK.md
new file mode 100644
index 0000000..56ec064
--- /dev/null
+++ b/.planning/codebase/STACK.md
@@ -0,0 +1,86 @@
+# Technology Stack
+
+**Analysis Date:** 2026-04-30
+
+## Languages
+
+**Primary:**
+- TypeScript (ESM) - Backend and frontend application code in `backend/src/**/*.ts` and `frontend/src/**/*.{ts,tsx}`
+- SQL (SQLite migrations) - Schema evolution files in `backend/drizzle/*.sql`
+
+**Secondary:**
+- CSS - UI styling in `frontend/src/**/*.css` and CSS modules such as `frontend/src/features/schedule/TimelineSurface.module.css`
+- YAML - CI/CD and compose configuration in `.github/workflows/*.yml`, `docker-compose.yml`, `docker-compose.dev.yml`
+- Shell - Container/runtime entrypoints in `backend/docker-entrypoint.sh`, `frontend/nginx-entrypoint.sh`
+
+## Runtime
+
+**Environment:**
+- Node.js 22 runtime baseline (`node:22-slim` in `backend/Dockerfile`, `frontend/Dockerfile`; `actions/setup-node@v6` with `node-version: '22'` in `.github/workflows/test.yml` and `.github/workflows/e2e.yml`)
+
+**Package Manager:**
+- npm (scripts in root `package.json`, `backend/package.json`, `frontend/package.json`)
+- Lockfile: present (`backend/package-lock.json`, `frontend/package-lock.json` referenced by workflow cache in `.github/workflows/test.yml`)
+
+## Frameworks
+
+**Core:**
+- Fastify 5 (`fastify`, `@fastify/*` in `backend/package.json`; app bootstrap in `backend/src/index.ts`)
+- React 19 (`react`, `react-dom` in `frontend/package.json`; app entry in `frontend/src/main.tsx`)
+- Vite 8 (`vite` and `@vitejs/plugin-react` in `frontend/package.json`; config in `frontend/vite.config.ts`)
+- Drizzle ORM + libSQL client (`drizzle-orm`, `@libsql/client` in `backend/package.json`; DB init in `backend/src/db/client.ts`)
+- Mantine 8 UI system (`@mantine/*` in `frontend/package.json`; provider in `frontend/src/ui/providers/AppUiProvider.tsx`)
+
+**Testing:**
+- Vitest 4 (`vitest`, `@vitest/coverage-v8` in backend/frontend package manifests; configs in `backend/vitest.config.ts`, `frontend/vitest.config.ts`)
+- Playwright (`@playwright/test` in `frontend/package.json`; configs in `frontend/playwright*.config.ts`; CI run in `.github/workflows/e2e.yml`)
+- Testing Library (`@testing-library/*` in `frontend/package.json`)
+
+**Build/Dev:**
+- TypeScript compiler (`tsc` scripts in `backend/package.json` and frontend type-check via `frontend/package.json`)
+- TSX watcher for backend dev (`tsx watch src/index.ts` in `backend/package.json`)
+- Biome for lint/format (`biome.json`, lint/check scripts across package manifests)
+- Drizzle Kit for DB migration generation (`drizzle-kit` in `backend/package.json`, config in `backend/drizzle.config.ts`)
+
+## Key Dependencies
+
+**Critical:**
+- `fastify` and `@fastify/*` - HTTP API runtime, security middleware, docs middleware (`backend/src/index.ts`)
+- `drizzle-orm` + `@libsql/client` - SQLite data access and migration execution (`backend/src/db/client.ts`)
+- `openid-client` + `jose` - OIDC SSO and token operations (`backend/src/routes/oidc.ts`, `backend/package.json`)
+- `nodemailer` - SMTP notification delivery (`backend/src/services/notifications/delivery.ts`)
+- `react`, `react-router-dom`, `@mantine/*` - SPA UI shell, routing, and component system (`frontend/src/main.tsx`, `frontend/src/App.tsx`)
+- `i18next` + `react-i18next` - Localization runtime (`frontend/src/i18n/index.ts`)
+
+**Infrastructure:**
+- `dotenv` + `zod` - env loading/validation (`backend/src/plugins/env.ts`)
+- `sharp` - image processing pipeline support (`backend/package.json`, image route usage in medication flows)
+- `@fastify/swagger` + `@fastify/swagger-ui` - OpenAPI docs on `/docs` (`backend/src/index.ts`)
+
+## Configuration
+
+**Environment:**
+- Runtime env schema and validation in `backend/src/plugins/env.ts`
+- Example variable inventory in `.env.example`
+- Frontend proxy target via `BACKEND_URL` in `frontend/vite.config.ts` and compose files
+
+**Build:**
+- Backend TS build config: `backend/tsconfig.json`
+- Frontend TS + Vite config: `frontend/tsconfig.json`, `frontend/tsconfig.node.json`, `frontend/vite.config.ts`
+- DB migration tooling config: `backend/drizzle.config.ts`
+- Quality tooling config: `biome.json`
+
+## Platform Requirements
+
+**Development:**
+- Node.js 22 with npm for local runs (`backend/package.json`, `frontend/package.json` scripts)
+- Optional Docker Compose local stack (`docker-compose.dev.yml`)
+- Browser runtime for frontend and Playwright browser binaries for E2E (`frontend/package.json`, `.github/workflows/e2e.yml`)
+
+**Production:**
+- Containerized deployment using prebuilt images from GHCR (`docker-compose.yml` references `ghcr.io/danielvolz/medassist-ng-backend:latest` and `ghcr.io/danielvolz/medassist-ng-frontend:latest`)
+- Backend persistent filesystem for SQLite/data in mounted `./data` (`docker-compose.yml`, DB path resolver in `backend/src/db/path-utils.ts`)
+
+---
+
+*Stack analysis: 2026-04-30*
diff --git a/.planning/codebase/STRUCTURE.md b/.planning/codebase/STRUCTURE.md
new file mode 100644
index 0000000..b569cc2
--- /dev/null
+++ b/.planning/codebase/STRUCTURE.md
@@ -0,0 +1,138 @@
+# Codebase Structure
+
+**Analysis Date:** 2026-04-30
+
+## Directory Layout
+
+```
+medassist/
+├── frontend/ # React + Vite SPA, UI, hooks, page routes, frontend tests
+├── backend/ # Fastify API, domain services, DB schema/migrations, backend tests
+├── backend/drizzle/ # SQL migration files + drizzle meta journal
+├── docs/ # Product/ops docs and screenshots
+├── doku/ # Local-only working notes and reports (ignored)
+├── .github/ # CI workflows, agents, local skill/runtime metadata
+├── .planning/codebase/ # Generated codebase mapping documents
+├── data/ # Runtime/local SQLite backups and scheduler files
+└── package.json # Root workspace scripts for lint orchestration
+```
+
+## Directory Purposes
+
+**frontend/src:**
+- Purpose: Product UI and client-side app logic.
+- Contains: `pages/`, `components/`, `context/`, `hooks/`, `ui/`, `utils/`, `i18n/`, `test/`.
+- Key files: `frontend/src/main.tsx`, `frontend/src/App.tsx`, `frontend/src/context/AppContext.tsx`.
+
+**backend/src:**
+- Purpose: HTTP API, auth, domain services, and persistence access.
+- Contains: `routes/`, `services/`, `plugins/`, `db/`, `utils/`, `test/`.
+- Key files: `backend/src/index.ts`, `backend/src/routes/medications.ts`, `backend/src/routes/planner.ts`, `backend/src/db/client.ts`.
+
+**backend/drizzle:**
+- Purpose: SQL migration history for SQLite compatibility.
+- Contains: numbered migration files and `meta/_journal.json`.
+- Key files: `backend/drizzle/0000_init.sql`, `backend/drizzle/0014_add_user_settings_timezone.sql`.
+
+**frontend/e2e:**
+- Purpose: Playwright end-to-end scenarios and fixtures.
+- Contains: browser tests + auth fixtures.
+- Key files: `frontend/e2e/fixtures/` and spec files under `frontend/e2e/`.
+
+**docs + doku:**
+- Purpose: formal docs (`docs/`) and local-only work tracking (`doku/`).
+- Contains: behavior/spec docs, screenshots, local report/memory logs.
+- Key files: `docs/TECH_STACK.md`, `doku/memory_notes.md`, `doku/report.md`.
+
+## Key File Locations
+
+**Entry Points:**
+- `frontend/src/main.tsx`: Browser bootstrap; mounts providers and router.
+- `frontend/src/App.tsx`: Route graph and global modal/shell orchestration.
+- `backend/src/index.ts`: Fastify app setup + startup runtime.
+
+**Configuration:**
+- `frontend/vite.config.ts`: Dev server, `/api` proxy rewrite, build-time constants.
+- `frontend/vitest.config.ts`: Frontend unit test config.
+- `backend/vitest.config.ts`: Backend unit/integration test config.
+- `backend/drizzle.config.ts`: Drizzle migration configuration.
+- `.gitignore`: Local-only/generated path policy (including `.planning/`, `doku/`, `data/`, coverage/test artifacts).
+
+**Core Logic:**
+- `backend/src/routes/`: API contracts and request handlers.
+- `backend/src/services/`: Scheduler, notifications, medication helpers.
+- `backend/src/db/schema.ts`: Source-of-truth table definitions.
+- `frontend/src/context/`: Shared app orchestration state.
+- `frontend/src/pages/`: Screen-level composition.
+
+**Testing:**
+- `frontend/src/test/`: Frontend unit/component tests.
+- `frontend/e2e/`: Playwright E2E tests.
+- `backend/src/test/`: Backend route/service/db tests.
+
+## Naming Conventions
+
+**Files:**
+- React components/pages use PascalCase: `frontend/src/pages/MedicationsPage.tsx`, `frontend/src/components/MedDetailModal.tsx`.
+- Hooks use `use*` naming: `frontend/src/hooks/useMedications.ts`, `frontend/src/hooks/useSettings.ts`.
+- Backend routes/services use kebab-case: `backend/src/routes/medication-enrichment.ts`, `backend/src/services/reminder-scheduler.ts`.
+- Migrations use numbered descriptive names: `backend/drizzle/0012_add_api_keys_and_package_amount_columns.sql`.
+
+**Directories:**
+- Feature/layer folders are lowercase: `frontend/src/context`, `backend/src/services`.
+- Test directories stay colocated by runtime side (`frontend/src/test`, `backend/src/test`).
+
+## Where to Add New Code
+
+**New Feature:**
+- Primary code:
+ - Frontend UI route/screen: `frontend/src/pages/` (compose from existing `components/`, `hooks/`, `ui/`).
+ - Backend endpoint: `backend/src/routes/` + matching domain logic in `backend/src/services/`.
+ - Persistence additions: `backend/src/db/schema.ts` plus migration updates in `backend/src/db/client.ts` and `backend/drizzle/`.
+- Tests:
+ - Frontend unit/component: `frontend/src/test/`.
+ - Backend unit/integration: `backend/src/test/`.
+ - E2E flow: `frontend/e2e/`.
+
+**New Component/Module:**
+- Implementation:
+ - Shared UI primitive/layout: `frontend/src/ui/`.
+ - Domain-specific UI component: `frontend/src/components/` (or nested feature folder).
+ - Backend reusable domain behavior: `backend/src/services/`.
+
+**Utilities:**
+- Shared helpers:
+ - Frontend: `frontend/src/utils/`.
+ - Backend: `backend/src/utils/`.
+ - DB-specific helpers: `backend/src/db/` focused utility modules.
+
+## Special Directories
+
+**frontend/dist, backend/dist:**
+- Purpose: build output artifacts.
+- Generated: Yes.
+- Committed: No (`dist/` ignored in `.gitignore`).
+
+**frontend/playwright-report, frontend/test-results, frontend/coverage, backend/coverage:**
+- Purpose: test artifacts/reports.
+- Generated: Yes.
+- Committed: No (ignored in `.gitignore`).
+
+**data/:**
+- Purpose: runtime/local DB, reminder state, scheduler locks.
+- Generated: Yes.
+- Committed: No (`data/` ignored in `.gitignore`).
+
+**doku/:**
+- Purpose: local work memory/reporting and internal notes.
+- Generated: Mixed (manual local notes + artifacts).
+- Committed: No (`doku/` ignored in `.gitignore`).
+
+**.planning/codebase/:**
+- Purpose: generated architecture/stack/convention/concern maps for GSD planning/execution.
+- Generated: Yes.
+- Committed: No (`.planning/` ignored by policy in this workspace).
+
+---
+
+*Structure analysis: 2026-04-30*
diff --git a/.planning/codebase/TESTING.md b/.planning/codebase/TESTING.md
new file mode 100644
index 0000000..68b80ab
--- /dev/null
+++ b/.planning/codebase/TESTING.md
@@ -0,0 +1,203 @@
+# Testing Patterns
+
+**Analysis Date:** 2026-04-30
+
+## Test Framework
+
+**Runner:**
+- Vitest 4.x for unit/integration tests in both packages:
+ - Frontend config: `frontend/vitest.config.ts`
+ - Backend config: `backend/vitest.config.ts`
+- Config evidence:
+ - Frontend uses `environment: 'jsdom'` with React setup file `frontend/src/test/setup.ts`.
+ - Backend uses `environment: 'node'` with setup file `backend/src/test/setup.ts`.
+
+**Assertion Library:**
+- Vitest `expect`.
+- Frontend extends DOM assertions via `@testing-library/jest-dom` in `frontend/src/test/setup.ts`.
+
+**Run Commands:**
+```bash
+cd frontend && npm test # Watch/unit tests
+cd frontend && npm run test:run # CI-style frontend run
+cd frontend && npm run test:coverage # Frontend coverage
+cd backend && npm test # Watch/unit tests
+cd backend && npm run test:run # CI-style backend run
+cd backend && npm run test:coverage # Backend coverage
+cd frontend && npm run test:e2e # Stable Playwright suite
+cd frontend && npm run test:e2e:all # Cross-browser Playwright suite
+```
+
+## Test File Organization
+
+**Location:**
+- Backend unit/integration tests are in `backend/src/test/*.test.ts`.
+- Frontend unit/component/hook/context tests are in `frontend/src/test/**`.
+- Browser E2E tests are in `frontend/e2e/*.spec.ts`.
+
+**Naming:**
+- Unit/integration: `*.test.ts` or `*.test.tsx` (for example `backend/src/test/routes-real.test.ts`, `frontend/src/test/components/MedicationDialogs.test.tsx`).
+- E2E: `*.spec.ts` (for example `frontend/e2e/medication-edit.spec.ts`).
+
+**Structure:**
+```
+backend/src/test/
+ setup.ts
+ *.test.ts
+
+frontend/src/test/
+ setup.ts
+ App.test.tsx
+ components/*.test.tsx
+ context/*.test.tsx
+ hooks/*.test.ts
+ pages/*.test.tsx
+ utils/*.test.ts
+
+frontend/e2e/
+ auth.setup.ts
+ fixtures/index.ts
+ *.spec.ts
+```
+
+## Test Structure
+
+**Suite Organization:**
+```typescript
+describe("Feature Area", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("handles expected behavior", async () => {
+ // arrange
+ // act
+ // assert
+ expect(result).toEqual(expected);
+ });
+});
+```
+Pattern evidence: `frontend/src/test/components/MobileEditModal.test.tsx`, `backend/src/test/planner.test.ts`.
+
+**Patterns:**
+- Setup pattern:
+ - Frontend centralizes browser mocks in `frontend/src/test/setup.ts` (fetch, localStorage, clipboard, history, i18n).
+ - Backend provides reusable app/database factories in `backend/src/test/setup.ts` (`buildTestApp`, `createTestUser`, `createTestMedication`).
+- Teardown pattern:
+ - `afterAll` closes Fastify app and DB clients (`backend/src/test/planner.test.ts`, `backend/src/test/integration.test.ts`).
+- Assertion pattern:
+ - Route tests assert both HTTP status and response body (`backend/src/test/routes-real.test.ts`).
+ - UI tests assert presence and behavior via Testing Library role/test-id queries (`frontend/src/test/components/MedicationDialogs.test.tsx`).
+
+## Mocking
+
+**Framework:**
+- Vitest mocks (`vi.mock`, `vi.fn`, `vi.hoisted`, `vi.stubGlobal`).
+
+**Patterns:**
+```typescript
+const { testClient, testDb } = vi.hoisted(() => {
+ const client = createClient({ url: ":memory:" });
+ const db = drizzle(client);
+ return { testClient: client, testDb: db };
+});
+
+vi.mock("../db/client.js", () => ({
+ db: testDb,
+ migrationsReady: Promise.resolve(),
+}));
+```
+Pattern evidence: `backend/src/test/integration.test.ts`, `backend/src/test/routes-real.test.ts`.
+
+```typescript
+vi.mock("../../components/ConfirmModal", () => ({
+ ConfirmModal: ({ onConfirm }) => ,
+}));
+```
+Pattern evidence: `frontend/src/test/components/MedicationDialogs.test.tsx`.
+
+**What to Mock:**
+- External side effects and infrastructure boundaries: SMTP/nodemailer, fetch network calls, auth/plugin env modules, browser APIs.
+- Component dependencies in focused unit tests (replace heavy children with stubs).
+
+**What NOT to Mock:**
+- Core business behavior under direct test (route handlers in route tests, hook logic in hook tests, E2E API + UI flow in Playwright).
+
+## Fixtures and Factories
+
+**Test Data:**
+```typescript
+const userId = await createTestUser(client, { username: "testuser" });
+const medId = await createTestMedication(client, { userId, name: "Test Medication" });
+```
+Pattern evidence: `backend/src/test/setup.ts`, used by `backend/src/test/medications.test.ts`.
+
+```typescript
+export const test = base.extend({
+ page: async ({ page }, use) => {
+ await applyVideoSafetyMode(page);
+ await setupAuthMeMock(page);
+ await use(page);
+ },
+});
+```
+Pattern evidence: `frontend/e2e/fixtures/index.ts`.
+
+**Location:**
+- Backend factories/utilities: `backend/src/test/setup.ts`.
+- Frontend E2E shared fixtures and API helpers: `frontend/e2e/fixtures/index.ts`.
+
+## Coverage
+
+**Requirements:**
+- Frontend global thresholds in `frontend/vitest.config.ts`: lines/functions/branches/statements = 75.
+- Backend global thresholds in `backend/vitest.config.ts`: lines 60, functions 65, branches 50, statements 60.
+
+**View Coverage:**
+```bash
+cd frontend && npm run test:coverage
+cd backend && npm run test:coverage
+```
+
+## Test Types
+
+**Unit Tests:**
+- Component/hook/utils tests in `frontend/src/test/**`.
+- Utility/service route-unit style tests in `backend/src/test/*.test.ts`.
+
+**Integration Tests:**
+- Backend route interaction and multi-route behavior tests in files like:
+ - `backend/src/test/integration.test.ts`
+ - `backend/src/test/routes-real.test.ts`
+
+**E2E Tests:**
+- Playwright used with setup project and browser projects (`frontend/playwright.base.config.ts`).
+- Auth/session and API seeding helpers in `frontend/e2e/fixtures/index.ts`.
+
+## Common Patterns
+
+**Async Testing:**
+```typescript
+await waitFor(() => {
+ expect(mockFn).toHaveBeenCalledTimes(1);
+});
+```
+Pattern evidence: `frontend/src/test/context/AppContext.test.tsx`.
+
+```typescript
+const response = await app.inject({ method: "GET", url: "/settings" });
+expect(response.statusCode).toBe(200);
+```
+Pattern evidence: `backend/src/test/routes-real.test.ts`.
+
+**Error Testing:**
+```typescript
+const response = await app.inject({ method: "POST", url: "/planner/send-email", payload: { rows: [] } });
+expect(response.statusCode).toBe(400);
+expect(response.json()).toEqual({ error: "Missing planner data" });
+```
+Pattern evidence: `backend/src/test/planner.test.ts`.
+
+---
+
+*Testing analysis: 2026-04-30*
diff --git a/README.md b/README.md
index fa620db..9e4d2d1 100644
--- a/README.md
+++ b/README.md
@@ -18,8 +18,8 @@
-
-
+
+
### 🤖 AI-Generated Code
@@ -120,19 +120,19 @@ Share your medication schedule with others via a public link.
### Medication Setup
-- Optional multi-source lookup inside the medication editor on desktop and mobile, prioritizing `RxNorm` and `openFDA` before `EMA`, including package-size suggestions when the source exposes them
-- Explicit review-and-apply flow with low-risk suggestions only
-- Additional lookup results can be revealed on demand instead of being hard-cut at the initial small result set
-- Honest incomplete-coverage messaging with source labels; manual entry always remains available
+- Optional medication lookup in the editor on desktop and mobile
+- Supports `RxNorm`, `openFDA`, and `EMA` with source labels
+- Review-and-apply flow with package-size suggestions when available
+- Manual entry remains available
### Smart Inventory
-- Track exact stock with package profiles (blister, bottle, tube, liquid container)
+- Track exact stock with package profiles (blister, bottle, tube, liquid container, inhaler, injection)
- Display remaining days of supply
- Automatic calculation based on intake schedule
-- Manual stock correction supports profile-specific stock semantics (sealed units + loose stock for blister, amount-based stock for bottle/tube/liquid)
+- Manual stock correction supports profile-specific stock semantics (sealed units + loose stock for blister, discrete capacity/current stock for bottle, inhaler, and injection, amount-based stock for tube and liquid container)
### Medication Refill
-- One-click refill with pack or loose pill options
+- One-click refill with package-aware refill options for discrete containers and amount-based packages
- Complete refill history per medication
- Automatic stock updates after each refill
@@ -148,7 +148,6 @@ Share your medication schedule with others via a public link.
### Trip Planner
- Calculate medication demand for a trip or date range with package-aware units
-- Plan ahead for vacations, business trips, or hospital stays
- Send demand reports via email or push notification
### Reports
@@ -158,17 +157,20 @@ Share your medication schedule with others via a public link.
### Multi-Person Support
- Manage medications for multiple people
- Share schedules via link. Recipients can mark doses as taken, you see it live
+- Optionally allow shared links to view and edit intake journal notes for their visible schedule window
- Optionally embed the medication overview directly on shared links via a settings toggle
### Data Export & Import
-- Export all your data (medications, dose history, settings) as JSON
+- Export all your data (medications, dose history, intake journal notes, settings) as JSON
+- Review validated import contents before replacing current data
+- Optionally download a fresh backup before confirming import
- Import previously exported data with automatic ID remapping
- Choose whether to include sensitive data in exports
### Notifications
- Email via SMTP
- Push notifications via ntfy, Pushover, Gotify, Telegram, Discord & more ([Shoutrrr](https://containrrr.dev/shoutrrr/))
-- Supports both stock warnings and intake reminders
+- Supports stock warnings and intake reminders
### Privacy & Security
- Fully self-hosted
@@ -189,201 +191,51 @@ docker compose -p medassist-ng up -d
Open `http://localhost:4174` and start tracking your medications.
+### Verify Deployment
+
+After the containers start, confirm the stack is actually healthy:
+
+1. Run `docker compose ps` and confirm the `backend` service is `healthy` and the `frontend` service is running.
+2. Open `http://localhost:3000/health` and confirm the backend responds with JSON that includes `"status":"ok"`.
+3. Open `http://localhost:4174` and confirm the app shell loads and can reach the API.
+
+If the frontend loads but API requests fail, check the backend health endpoint first and confirm `CORS_ORIGINS` includes the frontend origin you are using. If you plan to open reminder or share links from another device, set `PUBLIC_APP_URL` to the externally reachable app URL instead of relying on `localhost`.
+
# Configuration
-All configuration is done via environment variables in `.env`. Copy `.env.example` to get started.
+Configure the application with environment variables in `.env`. Keep the basic container settings in the README and use the dedicated docs for the full reference.
-### General
+### Initial Configuration
| Variable | Default | Description |
|----------|---------|-------------|
| `PUID` | `1000` | User ID for container file permissions |
| `PGID` | `1000` | Group ID for container file permissions |
| `PORT` | `3000` | Backend API port |
-| `CORS_ORIGINS` | `http://localhost:4174` | Allowed origins for CORS |
-| `LOG_LEVEL` | `info` | Log verbosity (`debug`, `info`, `warn`, `error`, `silent`). At `info` (default), high-frequency polling endpoints are suppressed. Set `debug` to see all requests. |
-| `OPENAPI_DOCS_ENABLED` | `auto` | Enables API docs in non-production by default. Set explicitly to `true`/`false` to override. |
-| `TZ` | `Europe/Berlin` | Server default timezone for scheduled reminders (can be overridden per user in Settings) |
+| `CORS_ORIGINS` | `http://localhost:4174` | Allowed frontend origins |
+| `TZ` | `Europe/Berlin` | Default timezone for reminders |
-Recommended values for API docs by environment:
-
-| Environment | Recommendation |
-|-------------|----------------|
-| Development | `OPENAPI_DOCS_ENABLED=true` |
-| Staging/Test | `OPENAPI_DOCS_ENABLED=true` |
-| Production | leave it unset, or set `OPENAPI_DOCS_ENABLED=false` |
-
-Notes:
-
-- If `OPENAPI_DOCS_ENABLED` is not set, docs are enabled outside production and disabled in production.
-- If `OPENAPI_DOCS_ENABLED=true`, docs are available on `/docs` and `/docs/json`.
-- If `OPENAPI_DOCS_ENABLED=false`, only the docs are disabled. The API still works normally.
-
-### Authentication
+Optional but commonly needed:
| Variable | Default | Description |
|----------|---------|-------------|
-| `AUTH_ENABLED` | `false` | Enable user authentication |
-| `REGISTRATION_ENABLED` | `false` | Allow new user registrations |
-| `JWT_SECRET` | — | Access token signing key (required if auth enabled) |
-| `REFRESH_SECRET` | — | Refresh token signing key (required if auth enabled) |
-| `COOKIE_SECRET` | — | Cookie signing key (required if auth enabled) |
-| `ACCESS_TOKEN_TTL_MINUTES` | `15` | Access token lifetime |
-| `REFRESH_TOKEN_TTL_DAYS` | `7` | Refresh token lifetime |
+| `PUBLIC_APP_URL` | — | Public base URL for notification action and share links |
-Generate secrets with: `openssl rand -hex 32`
+Detailed configuration references:
-### API Keys (Programmatic API Access)
-
-When `AUTH_ENABLED=true`, you can create personal API keys and call protected endpoints with:
-
-```bash
-Authorization: Bearer ma_...
-```
-
-Available scopes:
-
-- `read`: read-only access (`GET`, `HEAD`, `OPTIONS`)
-- `write`: read + write access
-
-Essential notes:
-
-- Create keys in the app when authentication is enabled.
-- The token is shown only once after creation.
-- Creating a new key automatically deactivates previously active keys for the same user.
-- API keys are stored hashed in the database.
-
-Example usage:
-
-```bash
-curl http://localhost:3000/settings \
- -H "Authorization: Bearer ma_..."
-```
-
-API reference:
-
-- Interactive docs: `/docs`
-- OpenAPI JSON: `/docs/json`
-- With the bundled frontend ingress, these paths work on the normal app URL as well, for example `http://localhost:4174/docs` when docs are enabled.
-- Key management endpoints for authenticated users:
- - `GET /auth/api-keys`
- - `POST /auth/api-keys`
- - `DELETE /auth/api-keys/:id`
-
-### OIDC / SSO
-
-| Variable | Default | Description |
-|----------|---------|-------------|
-| `OIDC_ENABLED` | `false` | Enable OIDC authentication |
-| `OIDC_ISSUER_URL` | — | OIDC provider URL |
-| `OIDC_CLIENT_ID` | — | Client ID from OIDC provider |
-| `OIDC_CLIENT_SECRET` | — | Client secret from OIDC provider |
-| `OIDC_REDIRECT_URI` | — | Full callback URL (e.g., `https://your-domain.com/api/auth/oidc/callback`) |
-| `OIDC_SCOPES` | `openid profile email` | Scopes to request |
-| `OIDC_USERNAME_CLAIM` | `preferred_username` | Claim for username |
-| `OIDC_AUTO_CREATE_USERS` | `true` | Auto-create users on first SSO login |
-| `OIDC_PROVIDER_NAME` | `SSO` | Name shown on login button |
-
-### Email (SMTP)
-
-| Variable | Default | Description |
-|----------|---------|-------------|
-| `SMTP_HOST` | — | SMTP server hostname |
-| `SMTP_PORT` | `587` | SMTP server port |
-| `SMTP_USER` | — | SMTP username |
-| `SMTP_PASS` | — | SMTP password |
-| `SMTP_TOKEN` | — | OAuth2/App token (takes precedence over password) |
-| `SMTP_FROM` | — | Sender email address |
-| `SMTP_SECURE` | `false` | Use TLS |
-
-### Reminders
-
-| Variable | Default | Description |
-|----------|---------|-------------|
-| `REMINDER_DAYS_BEFORE` | `7` | Days before stock runs out to send reminder |
-| `REMINDER_HOUR` | `6` | Hour to send daily reminders (24h format) |
-| `REMINDER_MINUTES_BEFORE` | `15` | Minutes before intake to send reminder |
-| `EXPIRY_WARNING_DAYS` | `30` | Days before expiry to show warning |
-
-Intake reminder timing uses IANA timezones. The server uses `TZ` as default, and each user can set an override in Settings. If no user timezone is set, reminders continue using the server default.
-
-### Push Notifications (Shoutrrr)
-
-MedAssist uses [Shoutrrr](https://containrrr.dev/shoutrrr/) for push notifications, supporting many services with a single URL format.
-
-**Implemented URL schemes in MedAssist:** `ntfy://`, `discord://`, `pushover://`, `gotify://`, `telegram://`, plus direct `https://` webhooks.
-
-This covers common providers like ntfy, Discord, Pushover, Gotify, Telegram, Slack webhooks, and many others via webhook URLs.
-
-Configure push notifications in Settings → Push, or set defaults via environment variables:
-
-| Variable | Default | Description |
-|----------|---------|-------------|
-| `DEFAULT_SHOUTRRR_ENABLED` | `false` | Enable push notifications by default |
-| `DEFAULT_SHOUTRRR_URL` | — | Shoutrrr URL (see examples below) |
-| `DEFAULT_SHOUTRRR_STOCK_REMINDERS` | `true` | Send stock warnings via push |
-| `DEFAULT_SHOUTRRR_INTAKE_REMINDERS` | `true` | Send intake reminders via push |
-
-### Default User Settings
-
-These defaults are applied when a new user is created. Once a user saves settings in the app, their values take precedence.
-
-Complete list and details:
-
-- [docs/DEFAULT_USER_SETTINGS.md](docs/DEFAULT_USER_SETTINGS.md)
-
-#### URL Examples
-
-**ntfy** (free, self-hostable):
-```
-ntfy://ntfy.sh/your-topic
-ntfy://user:password@your-server.com/topic
-```
-
-**Pushover** (free app for iOS/Android):
-```
-pushover://shoutrrr:API_TOKEN@USER_KEY/
-```
-Get your keys at [pushover.net](https://pushover.net/):
-- **User Key**: Shown on your dashboard (top right)
-- **API Token**: Create an application → copy the API Token
-
-**Gotify** (self-hosted):
-```
-gotify://your-server.com/TOKEN
-gotify://your-server.com:443/path/to/gotify/TOKEN?priority=1
-```
-
-**Discord**:
-```
-discord://TOKEN@WEBHOOK_ID
-```
-
-**Telegram**:
-```
-telegram://TOKEN@telegram?chats=CHAT_ID
-telegram://TOKEN@telegram?chats=@your_channel,-1001234567890
-```
-
-For all services and options, see the [Shoutrrr documentation](https://containrrr.dev/shoutrrr/v0.8/services/overview/).
+- Full configuration reference: [docs/CONFIGURATION.md](docs/CONFIGURATION.md)
+- Push notifications: [docs/PUSH_NOTIFICATIONS.md](docs/PUSH_NOTIFICATIONS.md)
+- Default user settings: [docs/DEFAULT_USER_SETTINGS.md](docs/DEFAULT_USER_SETTINGS.md)
# Development
-```bash
-docker compose -p medassist-dev -f docker-compose.dev.yml up
-```
+Development setup and local commands are documented in [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md).
-- Frontend: `http://localhost:5173` (hot reload)
-- Backend: `http://localhost:3000`
-- API docs UI: `http://localhost:3000/docs` (when docs are enabled)
-- OpenAPI JSON: `http://localhost:3000/docs/json` (when docs are enabled)
-
-Useful local commands:
+For cross-stack maintenance work and pre-PR validation, the repository root now exposes:
```bash
-npm run lint
-cd backend && npm run test:run
-cd frontend && npm run test:run
+npm run check
+npm run build
```
# Acknowledgements
diff --git a/backend/drizzle/0015_add_intake_journal.sql b/backend/drizzle/0015_add_intake_journal.sql
new file mode 100644
index 0000000..5e6f05a
--- /dev/null
+++ b/backend/drizzle/0015_add_intake_journal.sql
@@ -0,0 +1,15 @@
+CREATE TABLE `intake_journal` (
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
+ `user_id` integer NOT NULL,
+ `dose_tracking_id` integer NOT NULL,
+ `medication_id` integer NOT NULL,
+ `scheduled_for` integer NOT NULL,
+ `note` text NOT NULL,
+ `created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ `updated_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
+ FOREIGN KEY (`dose_tracking_id`) REFERENCES `dose_tracking`(`id`) ON UPDATE no action ON DELETE cascade,
+ FOREIGN KEY (`medication_id`) REFERENCES `medications`(`id`) ON UPDATE no action ON DELETE cascade
+);
+--> statement-breakpoint
+CREATE UNIQUE INDEX `intake_journal_dose_tracking_id_unique` ON `intake_journal` (`dose_tracking_id`);
diff --git a/backend/drizzle/0016_add_share_allow_journal_notes.sql b/backend/drizzle/0016_add_share_allow_journal_notes.sql
new file mode 100644
index 0000000..9272a2f
--- /dev/null
+++ b/backend/drizzle/0016_add_share_allow_journal_notes.sql
@@ -0,0 +1 @@
+ALTER TABLE `share_tokens` ADD `allow_journal_notes` integer DEFAULT false NOT NULL;
diff --git a/backend/drizzle/meta/0014_snapshot.json b/backend/drizzle/meta/0014_snapshot.json
new file mode 100644
index 0000000..b1ac916
--- /dev/null
+++ b/backend/drizzle/meta/0014_snapshot.json
@@ -0,0 +1,1236 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "0f9ec2df-4f3a-4ca2-931f-5eac5b8734ee",
+ "prevId": "be57622d-1bd6-425e-90ba-41952f2f15d6",
+ "tables": {
+ "api_keys": {
+ "name": "api_keys",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text(100)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key_hash": {
+ "name": "key_hash",
+ "type": "text(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token_prefix": {
+ "name": "token_prefix",
+ "type": "text(24)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text(10)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'write'"
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "CURRENT_TIMESTAMP"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "CURRENT_TIMESTAMP"
+ }
+ },
+ "indexes": {
+ "api_keys_key_hash_unique": {
+ "name": "api_keys_key_hash_unique",
+ "columns": [
+ "key_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "api_keys_user_id_users_id_fk": {
+ "name": "api_keys_user_id_users_id_fk",
+ "tableFrom": "api_keys",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "dose_tracking": {
+ "name": "dose_tracking",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "dose_id": {
+ "name": "dose_id",
+ "type": "text(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "taken_at": {
+ "name": "taken_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(strftime('%s','now'))"
+ },
+ "marked_by": {
+ "name": "marked_by",
+ "type": "text(100)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "taken_source": {
+ "name": "taken_source",
+ "type": "text(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'manual'"
+ },
+ "dismissed": {
+ "name": "dismissed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "dose_tracking_user_id_users_id_fk": {
+ "name": "dose_tracking_user_id_users_id_fk",
+ "tableFrom": "dose_tracking",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "medications": {
+ "name": "medications",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text(100)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "generic_name": {
+ "name": "generic_name",
+ "type": "text(100)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "taken_by_json": {
+ "name": "taken_by_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "package_type": {
+ "name": "package_type",
+ "type": "text(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'blister'"
+ },
+ "medication_form": {
+ "name": "medication_form",
+ "type": "text(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'tablet'"
+ },
+ "pill_form": {
+ "name": "pill_form",
+ "type": "text(20)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lifecycle_category": {
+ "name": "lifecycle_category",
+ "type": "text(30)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'refill_when_empty'"
+ },
+ "package_amount_value": {
+ "name": "package_amount_value",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "package_amount_unit": {
+ "name": "package_amount_unit",
+ "type": "text(10)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'ml'"
+ },
+ "pack_count": {
+ "name": "pack_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "blisters_per_pack": {
+ "name": "blisters_per_pack",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "pills_per_blister": {
+ "name": "pills_per_blister",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "total_pills": {
+ "name": "total_pills",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "loose_tablets": {
+ "name": "loose_tablets",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "stock_adjustment": {
+ "name": "stock_adjustment",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "last_stock_correction_at": {
+ "name": "last_stock_correction_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "pill_weight_mg": {
+ "name": "pill_weight_mg",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "dose_unit": {
+ "name": "dose_unit",
+ "type": "text(20)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'mg'"
+ },
+ "usage_json": {
+ "name": "usage_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "every_json": {
+ "name": "every_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "start_json": {
+ "name": "start_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "intakes_json": {
+ "name": "intakes_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "image_url": {
+ "name": "image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expiry_date": {
+ "name": "expiry_date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "intake_reminders_enabled": {
+ "name": "intake_reminders_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "medication_start_date": {
+ "name": "medication_start_date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "medication_end_date": {
+ "name": "medication_end_date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "auto_mark_obsolete_after_end_date": {
+ "name": "auto_mark_obsolete_after_end_date",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "is_obsolete": {
+ "name": "is_obsolete",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "obsolete_at": {
+ "name": "obsolete_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "prescription_enabled": {
+ "name": "prescription_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "prescription_authorized_refills": {
+ "name": "prescription_authorized_refills",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "prescription_remaining_refills": {
+ "name": "prescription_remaining_refills",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "prescription_low_refill_threshold": {
+ "name": "prescription_low_refill_threshold",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "prescription_expiry_date": {
+ "name": "prescription_expiry_date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "dismissed_until": {
+ "name": "dismissed_until",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "CURRENT_TIMESTAMP"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "medications_user_id_users_id_fk": {
+ "name": "medications_user_id_users_id_fk",
+ "tableFrom": "medications",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "refill_history": {
+ "name": "refill_history",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "medication_id": {
+ "name": "medication_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "packs_added": {
+ "name": "packs_added",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "loose_pills_added": {
+ "name": "loose_pills_added",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "used_prescription": {
+ "name": "used_prescription",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "refill_date": {
+ "name": "refill_date",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(strftime('%s','now'))"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "refill_history_medication_id_medications_id_fk": {
+ "name": "refill_history_medication_id_medications_id_fk",
+ "tableFrom": "refill_history",
+ "tableTo": "medications",
+ "columnsFrom": [
+ "medication_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "refill_history_user_id_users_id_fk": {
+ "name": "refill_history_user_id_users_id_fk",
+ "tableFrom": "refill_history",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "refresh_tokens": {
+ "name": "refresh_tokens",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token_id": {
+ "name": "token_id",
+ "type": "text(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rotated_at": {
+ "name": "rotated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "revoked": {
+ "name": "revoked",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "CURRENT_TIMESTAMP"
+ }
+ },
+ "indexes": {
+ "refresh_tokens_token_id_unique": {
+ "name": "refresh_tokens_token_id_unique",
+ "columns": [
+ "token_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "refresh_tokens_user_id_users_id_fk": {
+ "name": "refresh_tokens_user_id_users_id_fk",
+ "tableFrom": "refresh_tokens",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "share_tokens": {
+ "name": "share_tokens",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "taken_by": {
+ "name": "taken_by",
+ "type": "text(100)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "schedule_days": {
+ "name": "schedule_days",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 30
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "CURRENT_TIMESTAMP"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "share_tokens_token_unique": {
+ "name": "share_tokens_token_unique",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "share_tokens_user_id_users_id_fk": {
+ "name": "share_tokens_user_id_users_id_fk",
+ "tableFrom": "share_tokens",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "user_settings": {
+ "name": "user_settings",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email_enabled": {
+ "name": "email_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "notification_email": {
+ "name": "notification_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email_stock_reminders": {
+ "name": "email_stock_reminders",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "email_intake_reminders": {
+ "name": "email_intake_reminders",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "email_prescription_reminders": {
+ "name": "email_prescription_reminders",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "shoutrrr_enabled": {
+ "name": "shoutrrr_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "shoutrrr_url": {
+ "name": "shoutrrr_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "shoutrrr_stock_reminders": {
+ "name": "shoutrrr_stock_reminders",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "shoutrrr_intake_reminders": {
+ "name": "shoutrrr_intake_reminders",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "shoutrrr_prescription_reminders": {
+ "name": "shoutrrr_prescription_reminders",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "reminder_days_before": {
+ "name": "reminder_days_before",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 7
+ },
+ "repeat_daily_reminders": {
+ "name": "repeat_daily_reminders",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "skip_reminders_for_taken_doses": {
+ "name": "skip_reminders_for_taken_doses",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "repeat_reminders_enabled": {
+ "name": "repeat_reminders_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "reminder_repeat_interval_minutes": {
+ "name": "reminder_repeat_interval_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 30
+ },
+ "max_nagging_reminders": {
+ "name": "max_nagging_reminders",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 5
+ },
+ "low_stock_days": {
+ "name": "low_stock_days",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 30
+ },
+ "normal_stock_days": {
+ "name": "normal_stock_days",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 90
+ },
+ "high_stock_days": {
+ "name": "high_stock_days",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 180
+ },
+ "expiry_warning_days": {
+ "name": "expiry_warning_days",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 90
+ },
+ "language": {
+ "name": "language",
+ "type": "text(10)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'en'"
+ },
+ "timezone": {
+ "name": "timezone",
+ "type": "text(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "stock_calculation_mode": {
+ "name": "stock_calculation_mode",
+ "type": "text(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'automatic'"
+ },
+ "share_stock_status": {
+ "name": "share_stock_status",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "share_medication_overview": {
+ "name": "share_medication_overview",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "upcoming_today_only": {
+ "name": "upcoming_today_only",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "share_schedule_today_only": {
+ "name": "share_schedule_today_only",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "swap_dashboard_main_sections": {
+ "name": "swap_dashboard_main_sections",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "last_auto_email_sent": {
+ "name": "last_auto_email_sent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_notification_type": {
+ "name": "last_notification_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_notification_channel": {
+ "name": "last_notification_channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_reminder_med_name": {
+ "name": "last_reminder_med_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_reminder_taken_by": {
+ "name": "last_reminder_taken_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_stock_reminder_sent": {
+ "name": "last_stock_reminder_sent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_stock_reminder_channel": {
+ "name": "last_stock_reminder_channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_stock_reminder_med_names": {
+ "name": "last_stock_reminder_med_names",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_prescription_reminder_sent": {
+ "name": "last_prescription_reminder_sent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_prescription_reminder_channel": {
+ "name": "last_prescription_reminder_channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_prescription_reminder_med_names": {
+ "name": "last_prescription_reminder_med_names",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "CURRENT_TIMESTAMP"
+ }
+ },
+ "indexes": {
+ "user_settings_user_id_unique": {
+ "name": "user_settings_user_id_unique",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "user_settings_user_id_users_id_fk": {
+ "name": "user_settings_user_id_users_id_fk",
+ "tableFrom": "user_settings",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "users": {
+ "name": "users",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "username": {
+ "name": "username",
+ "type": "text(100)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "password_hash": {
+ "name": "password_hash",
+ "type": "text(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "avatar_url": {
+ "name": "avatar_url",
+ "type": "text(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "auth_provider": {
+ "name": "auth_provider",
+ "type": "text(50)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'local'"
+ },
+ "oidc_subject": {
+ "name": "oidc_subject",
+ "type": "text(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "last_login_at": {
+ "name": "last_login_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "CURRENT_TIMESTAMP"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "CURRENT_TIMESTAMP"
+ }
+ },
+ "indexes": {
+ "users_username_unique": {
+ "name": "users_username_unique",
+ "columns": [
+ "username"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
diff --git a/backend/drizzle/meta/0015_snapshot.json b/backend/drizzle/meta/0015_snapshot.json
new file mode 100644
index 0000000..250ac65
--- /dev/null
+++ b/backend/drizzle/meta/0015_snapshot.json
@@ -0,0 +1,1568 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "99838de4-6d3e-42e5-a319-21863bc2c7e3",
+ "prevId": "0f9ec2df-4f3a-4ca2-931f-5eac5b8734ee",
+ "tables": {
+ "api_keys": {
+ "name": "api_keys",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text(100)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key_hash": {
+ "name": "key_hash",
+ "type": "text(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token_prefix": {
+ "name": "token_prefix",
+ "type": "text(24)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text(10)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'write'"
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "CURRENT_TIMESTAMP"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "CURRENT_TIMESTAMP"
+ }
+ },
+ "indexes": {
+ "api_keys_key_hash_unique": {
+ "name": "api_keys_key_hash_unique",
+ "columns": [
+ "key_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "api_keys_user_id_users_id_fk": {
+ "name": "api_keys_user_id_users_id_fk",
+ "tableFrom": "api_keys",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "dose_tracking": {
+ "name": "dose_tracking",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "dose_id": {
+ "name": "dose_id",
+ "type": "text(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "taken_at": {
+ "name": "taken_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(strftime('%s','now'))"
+ },
+ "marked_by": {
+ "name": "marked_by",
+ "type": "text(100)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "taken_source": {
+ "name": "taken_source",
+ "type": "text(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'manual'"
+ },
+ "dismissed": {
+ "name": "dismissed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "dose_tracking_user_id_users_id_fk": {
+ "name": "dose_tracking_user_id_users_id_fk",
+ "tableFrom": "dose_tracking",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "intake_journal": {
+ "name": "intake_journal",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "dose_tracking_id": {
+ "name": "dose_tracking_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "medication_id": {
+ "name": "medication_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scheduled_for": {
+ "name": "scheduled_for",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "CURRENT_TIMESTAMP"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "CURRENT_TIMESTAMP"
+ }
+ },
+ "indexes": {
+ "intake_journal_dose_tracking_id_unique": {
+ "name": "intake_journal_dose_tracking_id_unique",
+ "columns": [
+ "dose_tracking_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "intake_journal_user_id_users_id_fk": {
+ "name": "intake_journal_user_id_users_id_fk",
+ "tableFrom": "intake_journal",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "intake_journal_dose_tracking_id_dose_tracking_id_fk": {
+ "name": "intake_journal_dose_tracking_id_dose_tracking_id_fk",
+ "tableFrom": "intake_journal",
+ "tableTo": "dose_tracking",
+ "columnsFrom": [
+ "dose_tracking_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "intake_journal_medication_id_medications_id_fk": {
+ "name": "intake_journal_medication_id_medications_id_fk",
+ "tableFrom": "intake_journal",
+ "tableTo": "medications",
+ "columnsFrom": [
+ "medication_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "medications": {
+ "name": "medications",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text(100)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "generic_name": {
+ "name": "generic_name",
+ "type": "text(100)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "taken_by_json": {
+ "name": "taken_by_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "package_type": {
+ "name": "package_type",
+ "type": "text(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'blister'"
+ },
+ "medication_form": {
+ "name": "medication_form",
+ "type": "text(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'tablet'"
+ },
+ "pill_form": {
+ "name": "pill_form",
+ "type": "text(20)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lifecycle_category": {
+ "name": "lifecycle_category",
+ "type": "text(30)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'refill_when_empty'"
+ },
+ "package_amount_value": {
+ "name": "package_amount_value",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "package_amount_unit": {
+ "name": "package_amount_unit",
+ "type": "text(10)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'ml'"
+ },
+ "pack_count": {
+ "name": "pack_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "blisters_per_pack": {
+ "name": "blisters_per_pack",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "pills_per_blister": {
+ "name": "pills_per_blister",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "total_pills": {
+ "name": "total_pills",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "loose_tablets": {
+ "name": "loose_tablets",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "stock_adjustment": {
+ "name": "stock_adjustment",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "last_stock_correction_at": {
+ "name": "last_stock_correction_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "pill_weight_mg": {
+ "name": "pill_weight_mg",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "dose_unit": {
+ "name": "dose_unit",
+ "type": "text(20)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'mg'"
+ },
+ "usage_json": {
+ "name": "usage_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "every_json": {
+ "name": "every_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "start_json": {
+ "name": "start_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "intakes_json": {
+ "name": "intakes_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "image_url": {
+ "name": "image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expiry_date": {
+ "name": "expiry_date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "intake_reminders_enabled": {
+ "name": "intake_reminders_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "medication_start_date": {
+ "name": "medication_start_date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "medication_end_date": {
+ "name": "medication_end_date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "auto_mark_obsolete_after_end_date": {
+ "name": "auto_mark_obsolete_after_end_date",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "is_obsolete": {
+ "name": "is_obsolete",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "obsolete_at": {
+ "name": "obsolete_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "prescription_enabled": {
+ "name": "prescription_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "prescription_authorized_refills": {
+ "name": "prescription_authorized_refills",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "prescription_remaining_refills": {
+ "name": "prescription_remaining_refills",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "prescription_low_refill_threshold": {
+ "name": "prescription_low_refill_threshold",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "prescription_expiry_date": {
+ "name": "prescription_expiry_date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "dismissed_until": {
+ "name": "dismissed_until",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "CURRENT_TIMESTAMP"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "medications_user_id_users_id_fk": {
+ "name": "medications_user_id_users_id_fk",
+ "tableFrom": "medications",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "notification_action_groups": {
+ "name": "notification_action_groups",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "group_key": {
+ "name": "group_key",
+ "type": "text(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sequence_id": {
+ "name": "sequence_id",
+ "type": "text(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ntfy_original_message_id": {
+ "name": "ntfy_original_message_id",
+ "type": "text(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "dose_ids_json": {
+ "name": "dose_ids_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "language": {
+ "name": "language",
+ "type": "text(10)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'en'"
+ },
+ "scheduled_for": {
+ "name": "scheduled_for",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "resolved_action": {
+ "name": "resolved_action",
+ "type": "text(20)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "resolved_at": {
+ "name": "resolved_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "CURRENT_TIMESTAMP"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "CURRENT_TIMESTAMP"
+ }
+ },
+ "indexes": {
+ "notification_action_groups_group_key_unique": {
+ "name": "notification_action_groups_group_key_unique",
+ "columns": [
+ "group_key"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "notification_action_groups_user_id_users_id_fk": {
+ "name": "notification_action_groups_user_id_users_id_fk",
+ "tableFrom": "notification_action_groups",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "notification_action_tokens": {
+ "name": "notification_action_tokens",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "used_at": {
+ "name": "used_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "CURRENT_TIMESTAMP"
+ }
+ },
+ "indexes": {
+ "notification_action_tokens_token_hash_unique": {
+ "name": "notification_action_tokens_token_hash_unique",
+ "columns": [
+ "token_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "notification_action_tokens_group_id_notification_action_groups_id_fk": {
+ "name": "notification_action_tokens_group_id_notification_action_groups_id_fk",
+ "tableFrom": "notification_action_tokens",
+ "tableTo": "notification_action_groups",
+ "columnsFrom": [
+ "group_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "refill_history": {
+ "name": "refill_history",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "medication_id": {
+ "name": "medication_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "packs_added": {
+ "name": "packs_added",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "loose_pills_added": {
+ "name": "loose_pills_added",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "used_prescription": {
+ "name": "used_prescription",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "refill_date": {
+ "name": "refill_date",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(strftime('%s','now'))"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "refill_history_medication_id_medications_id_fk": {
+ "name": "refill_history_medication_id_medications_id_fk",
+ "tableFrom": "refill_history",
+ "tableTo": "medications",
+ "columnsFrom": [
+ "medication_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "refill_history_user_id_users_id_fk": {
+ "name": "refill_history_user_id_users_id_fk",
+ "tableFrom": "refill_history",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "refresh_tokens": {
+ "name": "refresh_tokens",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token_id": {
+ "name": "token_id",
+ "type": "text(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rotated_at": {
+ "name": "rotated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "revoked": {
+ "name": "revoked",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "CURRENT_TIMESTAMP"
+ }
+ },
+ "indexes": {
+ "refresh_tokens_token_id_unique": {
+ "name": "refresh_tokens_token_id_unique",
+ "columns": [
+ "token_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "refresh_tokens_user_id_users_id_fk": {
+ "name": "refresh_tokens_user_id_users_id_fk",
+ "tableFrom": "refresh_tokens",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "share_tokens": {
+ "name": "share_tokens",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "taken_by": {
+ "name": "taken_by",
+ "type": "text(100)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "schedule_days": {
+ "name": "schedule_days",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 30
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "CURRENT_TIMESTAMP"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "share_tokens_token_unique": {
+ "name": "share_tokens_token_unique",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "share_tokens_user_id_users_id_fk": {
+ "name": "share_tokens_user_id_users_id_fk",
+ "tableFrom": "share_tokens",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "user_settings": {
+ "name": "user_settings",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email_enabled": {
+ "name": "email_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "notification_email": {
+ "name": "notification_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email_stock_reminders": {
+ "name": "email_stock_reminders",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "email_intake_reminders": {
+ "name": "email_intake_reminders",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "email_prescription_reminders": {
+ "name": "email_prescription_reminders",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "shoutrrr_enabled": {
+ "name": "shoutrrr_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "shoutrrr_url": {
+ "name": "shoutrrr_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "shoutrrr_stock_reminders": {
+ "name": "shoutrrr_stock_reminders",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "shoutrrr_intake_reminders": {
+ "name": "shoutrrr_intake_reminders",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "shoutrrr_prescription_reminders": {
+ "name": "shoutrrr_prescription_reminders",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "reminder_days_before": {
+ "name": "reminder_days_before",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 7
+ },
+ "repeat_daily_reminders": {
+ "name": "repeat_daily_reminders",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "skip_reminders_for_taken_doses": {
+ "name": "skip_reminders_for_taken_doses",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "repeat_reminders_enabled": {
+ "name": "repeat_reminders_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "reminder_repeat_interval_minutes": {
+ "name": "reminder_repeat_interval_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 30
+ },
+ "max_nagging_reminders": {
+ "name": "max_nagging_reminders",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 5
+ },
+ "low_stock_days": {
+ "name": "low_stock_days",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 30
+ },
+ "normal_stock_days": {
+ "name": "normal_stock_days",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 90
+ },
+ "high_stock_days": {
+ "name": "high_stock_days",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 180
+ },
+ "expiry_warning_days": {
+ "name": "expiry_warning_days",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 90
+ },
+ "language": {
+ "name": "language",
+ "type": "text(10)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'en'"
+ },
+ "timezone": {
+ "name": "timezone",
+ "type": "text(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "stock_calculation_mode": {
+ "name": "stock_calculation_mode",
+ "type": "text(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'automatic'"
+ },
+ "share_stock_status": {
+ "name": "share_stock_status",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "share_medication_overview": {
+ "name": "share_medication_overview",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "upcoming_today_only": {
+ "name": "upcoming_today_only",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "share_schedule_today_only": {
+ "name": "share_schedule_today_only",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "swap_dashboard_main_sections": {
+ "name": "swap_dashboard_main_sections",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "last_auto_email_sent": {
+ "name": "last_auto_email_sent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_notification_type": {
+ "name": "last_notification_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_notification_channel": {
+ "name": "last_notification_channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_reminder_med_name": {
+ "name": "last_reminder_med_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_reminder_taken_by": {
+ "name": "last_reminder_taken_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_stock_reminder_sent": {
+ "name": "last_stock_reminder_sent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_stock_reminder_channel": {
+ "name": "last_stock_reminder_channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_stock_reminder_med_names": {
+ "name": "last_stock_reminder_med_names",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_prescription_reminder_sent": {
+ "name": "last_prescription_reminder_sent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_prescription_reminder_channel": {
+ "name": "last_prescription_reminder_channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_prescription_reminder_med_names": {
+ "name": "last_prescription_reminder_med_names",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "CURRENT_TIMESTAMP"
+ }
+ },
+ "indexes": {
+ "user_settings_user_id_unique": {
+ "name": "user_settings_user_id_unique",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "user_settings_user_id_users_id_fk": {
+ "name": "user_settings_user_id_users_id_fk",
+ "tableFrom": "user_settings",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "users": {
+ "name": "users",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "username": {
+ "name": "username",
+ "type": "text(100)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "password_hash": {
+ "name": "password_hash",
+ "type": "text(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "avatar_url": {
+ "name": "avatar_url",
+ "type": "text(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "auth_provider": {
+ "name": "auth_provider",
+ "type": "text(50)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'local'"
+ },
+ "oidc_subject": {
+ "name": "oidc_subject",
+ "type": "text(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "last_login_at": {
+ "name": "last_login_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "CURRENT_TIMESTAMP"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "CURRENT_TIMESTAMP"
+ }
+ },
+ "indexes": {
+ "users_username_unique": {
+ "name": "users_username_unique",
+ "columns": [
+ "username"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/backend/drizzle/meta/0016_snapshot.json b/backend/drizzle/meta/0016_snapshot.json
new file mode 100644
index 0000000..a3da078
--- /dev/null
+++ b/backend/drizzle/meta/0016_snapshot.json
@@ -0,0 +1,1576 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "8e4c065c-4d73-4613-862a-91a69e7548e3",
+ "prevId": "99838de4-6d3e-42e5-a319-21863bc2c7e3",
+ "tables": {
+ "api_keys": {
+ "name": "api_keys",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text(100)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key_hash": {
+ "name": "key_hash",
+ "type": "text(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token_prefix": {
+ "name": "token_prefix",
+ "type": "text(24)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text(10)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'write'"
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "CURRENT_TIMESTAMP"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "CURRENT_TIMESTAMP"
+ }
+ },
+ "indexes": {
+ "api_keys_key_hash_unique": {
+ "name": "api_keys_key_hash_unique",
+ "columns": [
+ "key_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "api_keys_user_id_users_id_fk": {
+ "name": "api_keys_user_id_users_id_fk",
+ "tableFrom": "api_keys",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "dose_tracking": {
+ "name": "dose_tracking",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "dose_id": {
+ "name": "dose_id",
+ "type": "text(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "taken_at": {
+ "name": "taken_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(strftime('%s','now'))"
+ },
+ "marked_by": {
+ "name": "marked_by",
+ "type": "text(100)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "taken_source": {
+ "name": "taken_source",
+ "type": "text(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'manual'"
+ },
+ "dismissed": {
+ "name": "dismissed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "dose_tracking_user_id_users_id_fk": {
+ "name": "dose_tracking_user_id_users_id_fk",
+ "tableFrom": "dose_tracking",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "intake_journal": {
+ "name": "intake_journal",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "dose_tracking_id": {
+ "name": "dose_tracking_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "medication_id": {
+ "name": "medication_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scheduled_for": {
+ "name": "scheduled_for",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "CURRENT_TIMESTAMP"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "CURRENT_TIMESTAMP"
+ }
+ },
+ "indexes": {
+ "intake_journal_dose_tracking_id_unique": {
+ "name": "intake_journal_dose_tracking_id_unique",
+ "columns": [
+ "dose_tracking_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "intake_journal_user_id_users_id_fk": {
+ "name": "intake_journal_user_id_users_id_fk",
+ "tableFrom": "intake_journal",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "intake_journal_dose_tracking_id_dose_tracking_id_fk": {
+ "name": "intake_journal_dose_tracking_id_dose_tracking_id_fk",
+ "tableFrom": "intake_journal",
+ "tableTo": "dose_tracking",
+ "columnsFrom": [
+ "dose_tracking_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "intake_journal_medication_id_medications_id_fk": {
+ "name": "intake_journal_medication_id_medications_id_fk",
+ "tableFrom": "intake_journal",
+ "tableTo": "medications",
+ "columnsFrom": [
+ "medication_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "medications": {
+ "name": "medications",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text(100)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "generic_name": {
+ "name": "generic_name",
+ "type": "text(100)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "taken_by_json": {
+ "name": "taken_by_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "package_type": {
+ "name": "package_type",
+ "type": "text(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'blister'"
+ },
+ "medication_form": {
+ "name": "medication_form",
+ "type": "text(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'tablet'"
+ },
+ "pill_form": {
+ "name": "pill_form",
+ "type": "text(20)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lifecycle_category": {
+ "name": "lifecycle_category",
+ "type": "text(30)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'refill_when_empty'"
+ },
+ "package_amount_value": {
+ "name": "package_amount_value",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "package_amount_unit": {
+ "name": "package_amount_unit",
+ "type": "text(10)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'ml'"
+ },
+ "pack_count": {
+ "name": "pack_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "blisters_per_pack": {
+ "name": "blisters_per_pack",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "pills_per_blister": {
+ "name": "pills_per_blister",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "total_pills": {
+ "name": "total_pills",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "loose_tablets": {
+ "name": "loose_tablets",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "stock_adjustment": {
+ "name": "stock_adjustment",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "last_stock_correction_at": {
+ "name": "last_stock_correction_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "pill_weight_mg": {
+ "name": "pill_weight_mg",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "dose_unit": {
+ "name": "dose_unit",
+ "type": "text(20)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'mg'"
+ },
+ "usage_json": {
+ "name": "usage_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "every_json": {
+ "name": "every_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "start_json": {
+ "name": "start_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "intakes_json": {
+ "name": "intakes_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "image_url": {
+ "name": "image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expiry_date": {
+ "name": "expiry_date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "intake_reminders_enabled": {
+ "name": "intake_reminders_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "medication_start_date": {
+ "name": "medication_start_date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "medication_end_date": {
+ "name": "medication_end_date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "auto_mark_obsolete_after_end_date": {
+ "name": "auto_mark_obsolete_after_end_date",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "is_obsolete": {
+ "name": "is_obsolete",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "obsolete_at": {
+ "name": "obsolete_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "prescription_enabled": {
+ "name": "prescription_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "prescription_authorized_refills": {
+ "name": "prescription_authorized_refills",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "prescription_remaining_refills": {
+ "name": "prescription_remaining_refills",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "prescription_low_refill_threshold": {
+ "name": "prescription_low_refill_threshold",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "prescription_expiry_date": {
+ "name": "prescription_expiry_date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "dismissed_until": {
+ "name": "dismissed_until",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "CURRENT_TIMESTAMP"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "medications_user_id_users_id_fk": {
+ "name": "medications_user_id_users_id_fk",
+ "tableFrom": "medications",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "notification_action_groups": {
+ "name": "notification_action_groups",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "group_key": {
+ "name": "group_key",
+ "type": "text(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sequence_id": {
+ "name": "sequence_id",
+ "type": "text(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ntfy_original_message_id": {
+ "name": "ntfy_original_message_id",
+ "type": "text(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "dose_ids_json": {
+ "name": "dose_ids_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "language": {
+ "name": "language",
+ "type": "text(10)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'en'"
+ },
+ "scheduled_for": {
+ "name": "scheduled_for",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "resolved_action": {
+ "name": "resolved_action",
+ "type": "text(20)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "resolved_at": {
+ "name": "resolved_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "CURRENT_TIMESTAMP"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "CURRENT_TIMESTAMP"
+ }
+ },
+ "indexes": {
+ "notification_action_groups_group_key_unique": {
+ "name": "notification_action_groups_group_key_unique",
+ "columns": [
+ "group_key"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "notification_action_groups_user_id_users_id_fk": {
+ "name": "notification_action_groups_user_id_users_id_fk",
+ "tableFrom": "notification_action_groups",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "notification_action_tokens": {
+ "name": "notification_action_tokens",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "used_at": {
+ "name": "used_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "CURRENT_TIMESTAMP"
+ }
+ },
+ "indexes": {
+ "notification_action_tokens_token_hash_unique": {
+ "name": "notification_action_tokens_token_hash_unique",
+ "columns": [
+ "token_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "notification_action_tokens_group_id_notification_action_groups_id_fk": {
+ "name": "notification_action_tokens_group_id_notification_action_groups_id_fk",
+ "tableFrom": "notification_action_tokens",
+ "tableTo": "notification_action_groups",
+ "columnsFrom": [
+ "group_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "refill_history": {
+ "name": "refill_history",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "medication_id": {
+ "name": "medication_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "packs_added": {
+ "name": "packs_added",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "loose_pills_added": {
+ "name": "loose_pills_added",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "used_prescription": {
+ "name": "used_prescription",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "refill_date": {
+ "name": "refill_date",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(strftime('%s','now'))"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "refill_history_medication_id_medications_id_fk": {
+ "name": "refill_history_medication_id_medications_id_fk",
+ "tableFrom": "refill_history",
+ "tableTo": "medications",
+ "columnsFrom": [
+ "medication_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "refill_history_user_id_users_id_fk": {
+ "name": "refill_history_user_id_users_id_fk",
+ "tableFrom": "refill_history",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "refresh_tokens": {
+ "name": "refresh_tokens",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token_id": {
+ "name": "token_id",
+ "type": "text(255)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rotated_at": {
+ "name": "rotated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "revoked": {
+ "name": "revoked",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "CURRENT_TIMESTAMP"
+ }
+ },
+ "indexes": {
+ "refresh_tokens_token_id_unique": {
+ "name": "refresh_tokens_token_id_unique",
+ "columns": [
+ "token_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "refresh_tokens_user_id_users_id_fk": {
+ "name": "refresh_tokens_user_id_users_id_fk",
+ "tableFrom": "refresh_tokens",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "share_tokens": {
+ "name": "share_tokens",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "taken_by": {
+ "name": "taken_by",
+ "type": "text(100)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "schedule_days": {
+ "name": "schedule_days",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 30
+ },
+ "allow_journal_notes": {
+ "name": "allow_journal_notes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "CURRENT_TIMESTAMP"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "share_tokens_token_unique": {
+ "name": "share_tokens_token_unique",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "share_tokens_user_id_users_id_fk": {
+ "name": "share_tokens_user_id_users_id_fk",
+ "tableFrom": "share_tokens",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "user_settings": {
+ "name": "user_settings",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email_enabled": {
+ "name": "email_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "notification_email": {
+ "name": "notification_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email_stock_reminders": {
+ "name": "email_stock_reminders",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "email_intake_reminders": {
+ "name": "email_intake_reminders",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "email_prescription_reminders": {
+ "name": "email_prescription_reminders",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "shoutrrr_enabled": {
+ "name": "shoutrrr_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "shoutrrr_url": {
+ "name": "shoutrrr_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "shoutrrr_stock_reminders": {
+ "name": "shoutrrr_stock_reminders",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "shoutrrr_intake_reminders": {
+ "name": "shoutrrr_intake_reminders",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "shoutrrr_prescription_reminders": {
+ "name": "shoutrrr_prescription_reminders",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "reminder_days_before": {
+ "name": "reminder_days_before",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 7
+ },
+ "repeat_daily_reminders": {
+ "name": "repeat_daily_reminders",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "skip_reminders_for_taken_doses": {
+ "name": "skip_reminders_for_taken_doses",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "repeat_reminders_enabled": {
+ "name": "repeat_reminders_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "reminder_repeat_interval_minutes": {
+ "name": "reminder_repeat_interval_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 30
+ },
+ "max_nagging_reminders": {
+ "name": "max_nagging_reminders",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 5
+ },
+ "low_stock_days": {
+ "name": "low_stock_days",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 30
+ },
+ "normal_stock_days": {
+ "name": "normal_stock_days",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 90
+ },
+ "high_stock_days": {
+ "name": "high_stock_days",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 180
+ },
+ "expiry_warning_days": {
+ "name": "expiry_warning_days",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 90
+ },
+ "language": {
+ "name": "language",
+ "type": "text(10)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'en'"
+ },
+ "timezone": {
+ "name": "timezone",
+ "type": "text(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "stock_calculation_mode": {
+ "name": "stock_calculation_mode",
+ "type": "text(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'automatic'"
+ },
+ "share_stock_status": {
+ "name": "share_stock_status",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "share_medication_overview": {
+ "name": "share_medication_overview",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "upcoming_today_only": {
+ "name": "upcoming_today_only",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "share_schedule_today_only": {
+ "name": "share_schedule_today_only",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "swap_dashboard_main_sections": {
+ "name": "swap_dashboard_main_sections",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "last_auto_email_sent": {
+ "name": "last_auto_email_sent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_notification_type": {
+ "name": "last_notification_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_notification_channel": {
+ "name": "last_notification_channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_reminder_med_name": {
+ "name": "last_reminder_med_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_reminder_taken_by": {
+ "name": "last_reminder_taken_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_stock_reminder_sent": {
+ "name": "last_stock_reminder_sent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_stock_reminder_channel": {
+ "name": "last_stock_reminder_channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_stock_reminder_med_names": {
+ "name": "last_stock_reminder_med_names",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_prescription_reminder_sent": {
+ "name": "last_prescription_reminder_sent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_prescription_reminder_channel": {
+ "name": "last_prescription_reminder_channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_prescription_reminder_med_names": {
+ "name": "last_prescription_reminder_med_names",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "CURRENT_TIMESTAMP"
+ }
+ },
+ "indexes": {
+ "user_settings_user_id_unique": {
+ "name": "user_settings_user_id_unique",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "user_settings_user_id_users_id_fk": {
+ "name": "user_settings_user_id_users_id_fk",
+ "tableFrom": "user_settings",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "users": {
+ "name": "users",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "username": {
+ "name": "username",
+ "type": "text(100)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "password_hash": {
+ "name": "password_hash",
+ "type": "text(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "avatar_url": {
+ "name": "avatar_url",
+ "type": "text(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "auth_provider": {
+ "name": "auth_provider",
+ "type": "text(50)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'local'"
+ },
+ "oidc_subject": {
+ "name": "oidc_subject",
+ "type": "text(255)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "last_login_at": {
+ "name": "last_login_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "CURRENT_TIMESTAMP"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "CURRENT_TIMESTAMP"
+ }
+ },
+ "indexes": {
+ "users_username_unique": {
+ "name": "users_username_unique",
+ "columns": [
+ "username"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json
index 58499b8..f9c75ea 100644
--- a/backend/drizzle/meta/_journal.json
+++ b/backend/drizzle/meta/_journal.json
@@ -106,6 +106,20 @@
"when": 1775849300000,
"tag": "0014_add_user_settings_timezone",
"breakpoints": true
+ },
+ {
+ "idx": 15,
+ "version": "6",
+ "when": 1778962021119,
+ "tag": "0015_add_intake_journal",
+ "breakpoints": true
+ },
+ {
+ "idx": 16,
+ "version": "6",
+ "when": 1779044316043,
+ "tag": "0016_add_share_allow_journal_notes",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/backend/package-lock.json b/backend/package-lock.json
index ad523e1..5179a02 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -1,15 +1,16 @@
{
"name": "medassist-ng-backend",
- "version": "1.23.0",
+ "version": "1.25.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "medassist-ng-backend",
- "version": "1.23.0",
+ "version": "1.25.1",
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0",
+ "@fastify/formbody": "^8.0.2",
"@fastify/helmet": "^13.0.2",
"@fastify/multipart": "^10.0.0",
"@fastify/rate-limit": "^10.3.0",
@@ -23,22 +24,22 @@
"drizzle-orm": "^0.45.2",
"fastify": "^5.8.5",
"fastify-plugin": "^5.0.1",
- "jose": "^6.2.2",
- "nodemailer": "^8.0.6",
- "openid-client": "^6.8.3",
+ "jose": "^6.2.3",
+ "nodemailer": "^8.0.7",
+ "openid-client": "^6.8.4",
"sharp": "^0.34.5",
- "zod": "^3.23.8"
+ "zod": "^4.4.3"
},
"devDependencies": {
- "@biomejs/biome": "^2.4.13",
- "@types/node": "^25.6.0",
+ "@biomejs/biome": "^2.4.15",
+ "@types/node": "^25.8.0",
"@types/nodemailer": "^8.0.0",
"@types/supertest": "^7.2.0",
- "@vitest/coverage-v8": "^4.1.5",
+ "@vitest/coverage-v8": "^4.1.6",
"drizzle-kit": "^0.31.10",
"pino-pretty": "^13.1.3",
"supertest": "^7.2.2",
- "tsx": "^4.19.0",
+ "tsx": "^4.22.1",
"typescript": "^6.0.3",
"vitest": "^4.0.16"
}
@@ -104,9 +105,9 @@
}
},
"node_modules/@biomejs/biome": {
- "version": "2.4.13",
- "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.13.tgz",
- "integrity": "sha512-gLXOwkOBBg0tr7bDsqlkIh4uFeKuMjxvqsrb1Tukww1iDmHcfr4Uu8MoQxp0Rcte+69+osRNWXwHsu/zxT6XqA==",
+ "version": "2.4.15",
+ "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.15.tgz",
+ "integrity": "sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw==",
"dev": true,
"license": "MIT OR Apache-2.0",
"bin": {
@@ -120,20 +121,20 @@
"url": "https://opencollective.com/biome"
},
"optionalDependencies": {
- "@biomejs/cli-darwin-arm64": "2.4.13",
- "@biomejs/cli-darwin-x64": "2.4.13",
- "@biomejs/cli-linux-arm64": "2.4.13",
- "@biomejs/cli-linux-arm64-musl": "2.4.13",
- "@biomejs/cli-linux-x64": "2.4.13",
- "@biomejs/cli-linux-x64-musl": "2.4.13",
- "@biomejs/cli-win32-arm64": "2.4.13",
- "@biomejs/cli-win32-x64": "2.4.13"
+ "@biomejs/cli-darwin-arm64": "2.4.15",
+ "@biomejs/cli-darwin-x64": "2.4.15",
+ "@biomejs/cli-linux-arm64": "2.4.15",
+ "@biomejs/cli-linux-arm64-musl": "2.4.15",
+ "@biomejs/cli-linux-x64": "2.4.15",
+ "@biomejs/cli-linux-x64-musl": "2.4.15",
+ "@biomejs/cli-win32-arm64": "2.4.15",
+ "@biomejs/cli-win32-x64": "2.4.15"
}
},
"node_modules/@biomejs/cli-darwin-arm64": {
- "version": "2.4.13",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.13.tgz",
- "integrity": "sha512-2KImO1jhNFBa2oWConyr0x6flxbQpGKv6902uGXpYM62Xyem8U80j441SyUJ8KyngsmKbQjeIv1q2CQfDkNnYg==",
+ "version": "2.4.15",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.15.tgz",
+ "integrity": "sha512-rF3PPqLq1yoST79zaQbDjVJwsuIeci/O+9bgNmC5QpgOqz6aqYuzA4abyAGx+mgyiDXn4A049xAN8gijbuR1Qg==",
"cpu": [
"arm64"
],
@@ -148,9 +149,9 @@
}
},
"node_modules/@biomejs/cli-darwin-x64": {
- "version": "2.4.13",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.13.tgz",
- "integrity": "sha512-BKrJklbaFN4p1Ts4kPBczo+PkbsHQg57kmJ+vON9u2t6uN5okYHaSr7h/MutPCWQgg2lglaWoSmm+zhYW+oOkg==",
+ "version": "2.4.15",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.15.tgz",
+ "integrity": "sha512-/5KHXYMfSJs1fNXiX30xFtI8JcCFV6zaVVLxOa0M2sfqBKHkpQhRTv94yxQWxeTY2lzo2OuTlNvPC+hDQt2wcQ==",
"cpu": [
"x64"
],
@@ -165,9 +166,9 @@
}
},
"node_modules/@biomejs/cli-linux-arm64": {
- "version": "2.4.13",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.13.tgz",
- "integrity": "sha512-NzkUDSqfvMBrPplKgVr3aXLHZ2NEELvvF4vZxXulEylKWIGqlvNEcwUcj9OLrn75TD3lJ/GIqCVlBwd1MZCuYQ==",
+ "version": "2.4.15",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.15.tgz",
+ "integrity": "sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug==",
"cpu": [
"arm64"
],
@@ -182,9 +183,9 @@
}
},
"node_modules/@biomejs/cli-linux-arm64-musl": {
- "version": "2.4.13",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.13.tgz",
- "integrity": "sha512-U5MsuBQW25dXaYtqWWSPM3P96H6Y+fHuja3TQpMNnylocHW0tEbtFTDlUj6oM+YJLntvEkQy4grBvQNUD4+RCg==",
+ "version": "2.4.15",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.15.tgz",
+ "integrity": "sha512-ZPcxznxm0pogHBLZhYntyR3sR+MrZjqJIKEr7ZqVen0Rl+P/4upVmfYXjftizi9RoqZntg33fv/1fbdhbYXpEQ==",
"cpu": [
"arm64"
],
@@ -199,9 +200,9 @@
}
},
"node_modules/@biomejs/cli-linux-x64": {
- "version": "2.4.13",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.13.tgz",
- "integrity": "sha512-Az3ZZedYRBo9EQzNnD9SxFcR1G5QsGo6VEc2hIyVPZ1rdKwee/7E9oeBBZFpE8Z44ekxsDQBqbiWGW5ShOhUSQ==",
+ "version": "2.4.15",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.15.tgz",
+ "integrity": "sha512-0jj7THz12GbUOLmMibktK6DZjqz2zV64KFxyBtcFTKPiiOIY0a7vns1elpO1dERvxpsZ5ik0oFfz0oGwFde1+g==",
"cpu": [
"x64"
],
@@ -216,9 +217,9 @@
}
},
"node_modules/@biomejs/cli-linux-x64-musl": {
- "version": "2.4.13",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.13.tgz",
- "integrity": "sha512-Z601MienRgTBDza/+u2CH3RSrWoXo9rtr8NK6A4KJzqGgfxx+H3VlyLgTJ4sRo40T3pIsqpTmiOQEvYzQvBRvQ==",
+ "version": "2.4.15",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.15.tgz",
+ "integrity": "sha512-CNq/9W38SYSH023lfcQ4KKU8K0YX8T//FZUhcgtMMRABDojx5XsMV7jlweAvGSl389wJQB29Qo6Zb/a+jdvt+w==",
"cpu": [
"x64"
],
@@ -233,9 +234,9 @@
}
},
"node_modules/@biomejs/cli-win32-arm64": {
- "version": "2.4.13",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.13.tgz",
- "integrity": "sha512-Px9PS2B5/Q183bUwy/5VHqp3J2lzdOCeVGzMpphYfl8oSa7VDCqenBdqWpy6DCy/en4Rbf/Y1RieZF6dJPcc9A==",
+ "version": "2.4.15",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.15.tgz",
+ "integrity": "sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w==",
"cpu": [
"arm64"
],
@@ -250,9 +251,9 @@
}
},
"node_modules/@biomejs/cli-win32-x64": {
- "version": "2.4.13",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.13.tgz",
- "integrity": "sha512-tTcMkXyBrmHi9BfrD2VNHs/5rYIUKETqsBlYOvSAABwBkJhSDVb5e7wPukftsQbO3WzQkXe6kaztC6WtUOXSoQ==",
+ "version": "2.4.15",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.15.tgz",
+ "integrity": "sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ==",
"cpu": [
"x64"
],
@@ -895,6 +896,26 @@
"fast-json-stringify": "^6.0.0"
}
},
+ "node_modules/@fastify/formbody": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/@fastify/formbody/-/formbody-8.0.2.tgz",
+ "integrity": "sha512-84v5J2KrkXzjgBpYnaNRPqwgMsmY7ZDjuj0YVuMR3NXCJRCgKEZy/taSP1wUYGn0onfxJpLyRGDLa+NMaDJtnA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "fast-querystring": "^1.1.2",
+ "fastify-plugin": "^5.0.0"
+ }
+ },
"node_modules/@fastify/forwarded": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz",
@@ -1841,9 +1862,9 @@
}
},
"node_modules/@oxc-project/types": {
- "version": "0.127.0",
- "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
- "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==",
+ "version": "0.130.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz",
+ "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==",
"dev": true,
"license": "MIT",
"funding": {
@@ -1876,9 +1897,9 @@
"license": "MIT"
},
"node_modules/@rolldown/binding-android-arm64": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
- "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz",
+ "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==",
"cpu": [
"arm64"
],
@@ -1893,9 +1914,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz",
- "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz",
+ "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==",
"cpu": [
"arm64"
],
@@ -1910,9 +1931,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz",
- "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz",
+ "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==",
"cpu": [
"x64"
],
@@ -1927,9 +1948,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz",
- "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz",
+ "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==",
"cpu": [
"x64"
],
@@ -1944,9 +1965,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz",
- "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz",
+ "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==",
"cpu": [
"arm"
],
@@ -1961,9 +1982,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz",
- "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz",
+ "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==",
"cpu": [
"arm64"
],
@@ -1978,9 +1999,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz",
- "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz",
+ "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==",
"cpu": [
"arm64"
],
@@ -1995,9 +2016,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz",
- "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz",
+ "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==",
"cpu": [
"ppc64"
],
@@ -2012,9 +2033,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz",
- "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz",
+ "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==",
"cpu": [
"s390x"
],
@@ -2029,9 +2050,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz",
- "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz",
+ "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==",
"cpu": [
"x64"
],
@@ -2046,9 +2067,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz",
- "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz",
+ "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==",
"cpu": [
"x64"
],
@@ -2063,9 +2084,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz",
- "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz",
+ "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==",
"cpu": [
"arm64"
],
@@ -2080,9 +2101,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz",
- "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz",
+ "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==",
"cpu": [
"wasm32"
],
@@ -2099,9 +2120,9 @@
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz",
- "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz",
+ "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==",
"cpu": [
"arm64"
],
@@ -2116,9 +2137,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz",
- "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz",
+ "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==",
"cpu": [
"x64"
],
@@ -2133,9 +2154,9 @@
}
},
"node_modules/@rolldown/pluginutils": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz",
- "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true,
"license": "MIT"
},
@@ -2147,9 +2168,9 @@
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
- "version": "0.10.1",
- "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
- "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
+ "version": "0.10.2",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
+ "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -2197,12 +2218,12 @@
"license": "MIT"
},
"node_modules/@types/node": {
- "version": "25.6.0",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
- "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
+ "version": "25.8.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz",
+ "integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==",
"license": "MIT",
"dependencies": {
- "undici-types": "~7.19.0"
+ "undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/@types/nodemailer": {
@@ -2249,14 +2270,14 @@
}
},
"node_modules/@vitest/coverage-v8": {
- "version": "4.1.5",
- "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz",
- "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==",
+ "version": "4.1.6",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.6.tgz",
+ "integrity": "sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
- "@vitest/utils": "4.1.5",
+ "@vitest/utils": "4.1.6",
"ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
@@ -2270,8 +2291,8 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
- "@vitest/browser": "4.1.5",
- "vitest": "4.1.5"
+ "@vitest/browser": "4.1.6",
+ "vitest": "4.1.6"
},
"peerDependenciesMeta": {
"@vitest/browser": {
@@ -2280,16 +2301,16 @@
}
},
"node_modules/@vitest/expect": {
- "version": "4.1.5",
- "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
- "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==",
+ "version": "4.1.6",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz",
+ "integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
- "@vitest/spy": "4.1.5",
- "@vitest/utils": "4.1.5",
+ "@vitest/spy": "4.1.6",
+ "@vitest/utils": "4.1.6",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@@ -2298,13 +2319,13 @@
}
},
"node_modules/@vitest/mocker": {
- "version": "4.1.5",
- "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz",
- "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==",
+ "version": "4.1.6",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz",
+ "integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/spy": "4.1.5",
+ "@vitest/spy": "4.1.6",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -2325,9 +2346,9 @@
}
},
"node_modules/@vitest/pretty-format": {
- "version": "4.1.5",
- "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz",
- "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==",
+ "version": "4.1.6",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz",
+ "integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2338,13 +2359,13 @@
}
},
"node_modules/@vitest/runner": {
- "version": "4.1.5",
- "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz",
- "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==",
+ "version": "4.1.6",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz",
+ "integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/utils": "4.1.5",
+ "@vitest/utils": "4.1.6",
"pathe": "^2.0.3"
},
"funding": {
@@ -2352,14 +2373,14 @@
}
},
"node_modules/@vitest/snapshot": {
- "version": "4.1.5",
- "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz",
- "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==",
+ "version": "4.1.6",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz",
+ "integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "4.1.5",
- "@vitest/utils": "4.1.5",
+ "@vitest/pretty-format": "4.1.6",
+ "@vitest/utils": "4.1.6",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -2368,9 +2389,9 @@
}
},
"node_modules/@vitest/spy": {
- "version": "4.1.5",
- "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz",
- "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==",
+ "version": "4.1.6",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz",
+ "integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==",
"dev": true,
"license": "MIT",
"funding": {
@@ -2378,13 +2399,13 @@
}
},
"node_modules/@vitest/utils": {
- "version": "4.1.5",
- "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz",
- "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==",
+ "version": "4.1.6",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz",
+ "integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "4.1.5",
+ "@vitest/pretty-format": "4.1.6",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@@ -2515,9 +2536,9 @@
}
},
"node_modules/brace-expansion": {
- "version": "5.0.5",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
- "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
+ "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
@@ -3130,9 +3151,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
- "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
+ "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
"funding": [
{
"type": "github",
@@ -3580,9 +3601,9 @@
}
},
"node_modules/jose": {
- "version": "6.2.2",
- "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
- "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
+ "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
@@ -4147,9 +4168,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
- "version": "3.3.11",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
- "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "version": "3.3.12",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
+ "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"dev": true,
"funding": [
{
@@ -4186,9 +4207,9 @@
}
},
"node_modules/nodemailer": {
- "version": "8.0.6",
- "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.6.tgz",
- "integrity": "sha512-Nm2XeuDwwy2wi5A+8jPWwQwNzcjNjhWdE3pVLoXEusxJqCnAPAgnBGkSmiLknbnWuOF9qraRpYZjfxqtKZ4tPw==",
+ "version": "8.0.7",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz",
+ "integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
@@ -4253,9 +4274,9 @@
"license": "MIT"
},
"node_modules/openid-client": {
- "version": "6.8.3",
- "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.3.tgz",
- "integrity": "sha512-AoY/NaN9esS3+xvHInFSK0g3skSfeE0uqQAKRj4rB6/GsBIvzwTUaYo9+HcqpKIaP0dP85p5W07hayKgS4GAeA==",
+ "version": "6.8.4",
+ "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.4.tgz",
+ "integrity": "sha512-QSw0BA08piujetEwfZsHoTrDpMEha7GDZDicQqVwX4u0ChCjefvjDB++TZ8BTg76UpwhzIQgdvvfgfl3HpCSAw==",
"license": "MIT",
"dependencies": {
"jose": "^6.2.2",
@@ -4390,9 +4411,9 @@
"license": "MIT"
},
"node_modules/postcss": {
- "version": "8.5.12",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
- "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
+ "version": "8.5.14",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
+ "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"dev": true,
"funding": [
{
@@ -4527,14 +4548,14 @@
"license": "MIT"
},
"node_modules/rolldown": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
- "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
+ "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@oxc-project/types": "=0.127.0",
- "@rolldown/pluginutils": "1.0.0-rc.17"
+ "@oxc-project/types": "=0.130.0",
+ "@rolldown/pluginutils": "^1.0.0"
},
"bin": {
"rolldown": "bin/cli.mjs"
@@ -4543,21 +4564,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
- "@rolldown/binding-android-arm64": "1.0.0-rc.17",
- "@rolldown/binding-darwin-arm64": "1.0.0-rc.17",
- "@rolldown/binding-darwin-x64": "1.0.0-rc.17",
- "@rolldown/binding-freebsd-x64": "1.0.0-rc.17",
- "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17",
- "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17",
- "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17",
- "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17",
- "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17",
- "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17",
- "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17",
- "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17",
- "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17",
- "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17",
- "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17"
+ "@rolldown/binding-android-arm64": "1.0.1",
+ "@rolldown/binding-darwin-arm64": "1.0.1",
+ "@rolldown/binding-darwin-x64": "1.0.1",
+ "@rolldown/binding-freebsd-x64": "1.0.1",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.0.1",
+ "@rolldown/binding-linux-arm64-gnu": "1.0.1",
+ "@rolldown/binding-linux-arm64-musl": "1.0.1",
+ "@rolldown/binding-linux-ppc64-gnu": "1.0.1",
+ "@rolldown/binding-linux-s390x-gnu": "1.0.1",
+ "@rolldown/binding-linux-x64-gnu": "1.0.1",
+ "@rolldown/binding-linux-x64-musl": "1.0.1",
+ "@rolldown/binding-openharmony-arm64": "1.0.1",
+ "@rolldown/binding-wasm32-wasi": "1.0.1",
+ "@rolldown/binding-win32-arm64-msvc": "1.0.1",
+ "@rolldown/binding-win32-x64-msvc": "1.0.1"
}
},
"node_modules/safe-regex2": {
@@ -5011,14 +5032,13 @@
"optional": true
},
"node_modules/tsx": {
- "version": "4.21.0",
- "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
- "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
+ "version": "4.22.1",
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.1.tgz",
+ "integrity": "sha512-TvncJykhxAzFCk0VQZKBTClall4Pm7qXDSodb6uxi8QFa8X8mT6ABjxxsQ2opDRYxG7AzcRWXaFtruz5HJKuWg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "esbuild": "~0.27.0",
- "get-tsconfig": "^4.7.5"
+ "esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
@@ -5059,9 +5079,9 @@
}
},
"node_modules/undici-types": {
- "version": "7.19.2",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
- "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
+ "version": "7.24.6",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
+ "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"license": "MIT"
},
"node_modules/vary": {
@@ -5074,16 +5094,16 @@
}
},
"node_modules/vite": {
- "version": "8.0.10",
- "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
- "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==",
+ "version": "8.0.13",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
+ "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
- "postcss": "^8.5.10",
- "rolldown": "1.0.0-rc.17",
+ "postcss": "^8.5.14",
+ "rolldown": "1.0.1",
"tinyglobby": "^0.2.16"
},
"bin": {
@@ -5100,7 +5120,7 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
- "@vitejs/devtools": "^0.1.0",
+ "@vitejs/devtools": "^0.1.18",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
@@ -5152,19 +5172,19 @@
}
},
"node_modules/vitest": {
- "version": "4.1.5",
- "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz",
- "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
+ "version": "4.1.6",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz",
+ "integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/expect": "4.1.5",
- "@vitest/mocker": "4.1.5",
- "@vitest/pretty-format": "4.1.5",
- "@vitest/runner": "4.1.5",
- "@vitest/snapshot": "4.1.5",
- "@vitest/spy": "4.1.5",
- "@vitest/utils": "4.1.5",
+ "@vitest/expect": "4.1.6",
+ "@vitest/mocker": "4.1.6",
+ "@vitest/pretty-format": "4.1.6",
+ "@vitest/runner": "4.1.6",
+ "@vitest/snapshot": "4.1.6",
+ "@vitest/spy": "4.1.6",
+ "@vitest/utils": "4.1.6",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -5192,12 +5212,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
- "@vitest/browser-playwright": "4.1.5",
- "@vitest/browser-preview": "4.1.5",
- "@vitest/browser-webdriverio": "4.1.5",
- "@vitest/coverage-istanbul": "4.1.5",
- "@vitest/coverage-v8": "4.1.5",
- "@vitest/ui": "4.1.5",
+ "@vitest/browser-playwright": "4.1.6",
+ "@vitest/browser-preview": "4.1.6",
+ "@vitest/browser-webdriverio": "4.1.6",
+ "@vitest/coverage-istanbul": "4.1.6",
+ "@vitest/coverage-v8": "4.1.6",
+ "@vitest/ui": "4.1.6",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
@@ -5281,9 +5301,9 @@
"license": "ISC"
},
"node_modules/ws": {
- "version": "8.20.0",
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
- "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
+ "version": "8.20.1",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
+ "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -5317,9 +5337,9 @@
}
},
"node_modules/zod": {
- "version": "3.25.76",
- "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
- "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
diff --git a/backend/package.json b/backend/package.json
index 0e49f19..f18b258 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "medassist-ng-backend",
- "version": "1.23.0",
+ "version": "1.26.0",
"private": true,
"type": "module",
"scripts": {
@@ -19,6 +19,7 @@
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0",
+ "@fastify/formbody": "^8.0.2",
"@fastify/helmet": "^13.0.2",
"@fastify/multipart": "^10.0.0",
"@fastify/rate-limit": "^10.3.0",
@@ -32,22 +33,22 @@
"drizzle-orm": "^0.45.2",
"fastify": "^5.8.5",
"fastify-plugin": "^5.0.1",
- "jose": "^6.2.2",
- "nodemailer": "^8.0.6",
- "openid-client": "^6.8.3",
+ "jose": "^6.2.3",
+ "nodemailer": "^8.0.7",
+ "openid-client": "^6.8.4",
"sharp": "^0.34.5",
- "zod": "^3.23.8"
+ "zod": "^4.4.3"
},
"devDependencies": {
- "@biomejs/biome": "^2.4.13",
- "@types/node": "^25.6.0",
+ "@biomejs/biome": "^2.4.15",
+ "@types/node": "^25.8.0",
"@types/nodemailer": "^8.0.0",
"@types/supertest": "^7.2.0",
- "@vitest/coverage-v8": "^4.1.5",
+ "@vitest/coverage-v8": "^4.1.6",
"drizzle-kit": "^0.31.10",
"pino-pretty": "^13.1.3",
"supertest": "^7.2.2",
- "tsx": "^4.19.0",
+ "tsx": "^4.22.1",
"typescript": "^6.0.3",
"vitest": "^4.0.16"
},
diff --git a/backend/src/db/migration-utils.ts b/backend/src/db/migration-utils.ts
index 601ed8f..f6a7f47 100644
--- a/backend/src/db/migration-utils.ts
+++ b/backend/src/db/migration-utils.ts
@@ -59,6 +59,7 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_sent text`,
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_channel text`,
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_med_names text`,
+ // Keep the removed legacy setting column for backward compatibility with older SQLite files.
`ALTER TABLE user_settings ADD COLUMN share_stock_status integer NOT NULL DEFAULT 1`,
`ALTER TABLE user_settings ADD COLUMN share_medication_overview integer NOT NULL DEFAULT 0`,
`ALTER TABLE user_settings ADD COLUMN upcoming_today_only integer NOT NULL DEFAULT 0`,
@@ -75,6 +76,7 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_channel text`,
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_med_names text`,
`ALTER TABLE refill_history ADD COLUMN used_prescription integer NOT NULL DEFAULT 0`,
+ `ALTER TABLE share_tokens ADD COLUMN allow_journal_notes integer NOT NULL DEFAULT 0`,
];
for (const sql of alterMigrations) {
@@ -96,6 +98,41 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
loose_pills_added INTEGER NOT NULL DEFAULT 0,
refill_date INTEGER NOT NULL DEFAULT (strftime('%s','now'))
)`,
+ `CREATE TABLE IF NOT EXISTS intake_journal (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ dose_tracking_id INTEGER NOT NULL REFERENCES dose_tracking(id) ON DELETE CASCADE,
+ medication_id INTEGER NOT NULL REFERENCES medications(id) ON DELETE CASCADE,
+ scheduled_for INTEGER NOT NULL,
+ note TEXT NOT NULL,
+ created_at INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP
+ )`,
+ `CREATE TABLE IF NOT EXISTS notification_action_groups (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ group_key TEXT NOT NULL UNIQUE,
+ sequence_id TEXT NOT NULL,
+ ntfy_original_message_id TEXT NOT NULL DEFAULT '',
+ dose_ids_json TEXT NOT NULL,
+ title TEXT NOT NULL,
+ message TEXT NOT NULL,
+ language TEXT NOT NULL DEFAULT 'en',
+ scheduled_for INTEGER,
+ expires_at INTEGER NOT NULL,
+ resolved_action TEXT,
+ resolved_at INTEGER,
+ created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
+ updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
+ )`,
+ `CREATE TABLE IF NOT EXISTS notification_action_tokens (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ group_id INTEGER NOT NULL REFERENCES notification_action_groups(id) ON DELETE CASCADE,
+ token_hash TEXT NOT NULL UNIQUE,
+ kind TEXT NOT NULL,
+ used_at INTEGER,
+ created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
+ )`,
`CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -121,9 +158,26 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
}
}
+ const postCreateAlterMigrations = [
+ `ALTER TABLE notification_action_groups ADD COLUMN ntfy_original_message_id text NOT NULL DEFAULT ''`,
+ ];
+
+ for (const sql of postCreateAlterMigrations) {
+ try {
+ await client.execute(sql);
+ } catch (e: unknown) {
+ if (!(e as Error).message?.includes("duplicate column")) {
+ errors.push((e as Error).message);
+ }
+ }
+ }
+
const createIndexMigrations = [
`CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_unique ON users(lower(username))`,
`CREATE UNIQUE INDEX IF NOT EXISTS api_keys_key_hash_unique ON api_keys(key_hash)`,
+ `CREATE UNIQUE INDEX IF NOT EXISTS intake_journal_dose_tracking_id_unique ON intake_journal(dose_tracking_id)`,
+ `CREATE UNIQUE INDEX IF NOT EXISTS notification_action_groups_group_key_unique ON notification_action_groups(group_key)`,
+ `CREATE UNIQUE INDEX IF NOT EXISTS notification_action_tokens_token_hash_unique ON notification_action_tokens(token_hash)`,
`CREATE INDEX IF NOT EXISTS api_keys_user_id_idx ON api_keys(user_id)`,
];
diff --git a/backend/src/db/schema-sql.ts b/backend/src/db/schema-sql.ts
index a0fdcfe..8d12f79 100644
--- a/backend/src/db/schema-sql.ts
+++ b/backend/src/db/schema-sql.ts
@@ -100,6 +100,7 @@ export function getTableCreationSQL(): string[] {
token text NOT NULL UNIQUE,
taken_by text NOT NULL,
schedule_days integer NOT NULL DEFAULT 30,
+ allow_journal_notes integer NOT NULL DEFAULT 0,
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
expires_at integer,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts
index 99dba58..0bf81b3 100644
--- a/backend/src/db/schema.ts
+++ b/backend/src/db/schema.ts
@@ -108,8 +108,9 @@ export const userSettings = sqliteTable("user_settings", {
timezone: text("timezone", { length: 64 }).notNull().default(""),
// Stock calculation mode: "automatic" (schedule-based) or "manual" (only marked doses)
stockCalculationMode: text("stock_calculation_mode", { length: 20 }).notNull().default("automatic"),
- // Whether shared schedule links show stock status (Critical/Low/Normal) to intake users
- shareStockStatus: integer("share_stock_status", { mode: "boolean" }).notNull().default(true),
+ // Legacy column kept only so existing SQLite files continue to open cleanly after upgrades.
+ // Current MedAssist versions no longer read or expose this setting in product flows.
+ legacyShareStockStatusCompat: integer("share_stock_status", { mode: "boolean" }).notNull().default(true),
// Whether shared schedule links also embed the medication overview section
shareMedicationOverview: integer("share_medication_overview", { mode: "boolean" }).notNull().default(false),
// UI timeline visibility preferences
@@ -179,10 +180,48 @@ export const shareTokens = sqliteTable("share_tokens", {
token: text("token", { length: 64 }).notNull().unique(),
takenBy: text("taken_by", { length: 100 }).notNull(),
scheduleDays: integer("schedule_days").notNull().default(30),
+ allowJournalNotes: integer("allow_journal_notes", { mode: "boolean" }).notNull().default(false),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
expiresAt: integer("expires_at", { mode: "timestamp" }), // NULL = never expires
});
+// =============================================================================
+// Notification Action Groups - Shared action state for reminder notifications
+// =============================================================================
+export const notificationActionGroups = sqliteTable("notification_action_groups", {
+ id: integer("id").primaryKey({ autoIncrement: true }),
+ userId: integer("user_id")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ groupKey: text("group_key", { length: 255 }).notNull().unique(),
+ sequenceId: text("sequence_id", { length: 255 }).notNull(),
+ ntfyOriginalMessageId: text("ntfy_original_message_id", { length: 255 }).notNull().default(""),
+ doseIdsJson: text("dose_ids_json").notNull(),
+ title: text("title", { length: 255 }).notNull(),
+ message: text("message").notNull(),
+ language: text("language", { length: 10 }).notNull().default("en"),
+ scheduledFor: integer("scheduled_for", { mode: "timestamp" }),
+ expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
+ resolvedAction: text("resolved_action", { length: 20 }),
+ resolvedAt: integer("resolved_at", { mode: "timestamp" }),
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
+ updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
+});
+
+// =============================================================================
+// Notification Action Tokens - Hashed tokens for public reminder responses
+// =============================================================================
+export const notificationActionTokens = sqliteTable("notification_action_tokens", {
+ id: integer("id").primaryKey({ autoIncrement: true }),
+ groupId: integer("group_id")
+ .notNull()
+ .references(() => notificationActionGroups.id, { onDelete: "cascade" }),
+ tokenHash: text("token_hash", { length: 128 }).notNull().unique(),
+ kind: text("kind", { length: 20 }).notNull(),
+ usedAt: integer("used_at", { mode: "timestamp" }),
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
+});
+
// =============================================================================
// Dose Tracking - Tracks when doses are marked as taken
// =============================================================================
@@ -194,8 +233,29 @@ export const doseTracking = sqliteTable("dose_tracking", {
doseId: text("dose_id", { length: 255 }).notNull(), // e.g. "med-5-1-86400000-1735200000000"
takenAt: integer("taken_at", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link
- takenSource: text("taken_source", { length: 20 }).notNull().default("manual"), // manual or automatic
- dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // true = missed dose acknowledged without taking
+ takenSource: text("taken_source", { length: 20 }).notNull().default("manual"), // manual, automatic, or notification
+ dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // legacy column: true = intake skipped without stock deduction
+});
+
+// =============================================================================
+// Intake Journal - Optional owner-scoped note for a tracked dose event
+// =============================================================================
+export const intakeJournal = sqliteTable("intake_journal", {
+ id: integer("id").primaryKey({ autoIncrement: true }),
+ userId: integer("user_id")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ doseTrackingId: integer("dose_tracking_id")
+ .notNull()
+ .unique()
+ .references(() => doseTracking.id, { onDelete: "cascade" }),
+ medicationId: integer("medication_id")
+ .notNull()
+ .references(() => medications.id, { onDelete: "cascade" }),
+ scheduledFor: integer("scheduled_for", { mode: "timestamp" }).notNull(),
+ note: text("note").notNull(),
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
+ updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
});
// =============================================================================
diff --git a/backend/src/i18n/translations.ts b/backend/src/i18n/translations.ts
index 7dfcb43..7d2c7cc 100644
--- a/backend/src/i18n/translations.ts
+++ b/backend/src/i18n/translations.ts
@@ -109,6 +109,8 @@ type TranslationKeys = {
stockTitle: string;
stockTitleMultiple: string;
intakeTitle: string;
+ intakeTakenConfirmation: string;
+ intakeSkippedConfirmation: string;
pillsLeft: string;
daysLeft: string;
pillsAt: string;
@@ -179,6 +181,8 @@ type TranslationKeys = {
common: {
pill: string;
pills: string;
+ puffs: string;
+ injections: string;
units: string;
ml: string;
blister: string;
@@ -209,7 +213,7 @@ const translations: Record = {
descriptionLow: "The following medications are running low and should be reordered soon:",
tableHeaders: {
medication: "Medication",
- pills: "Pills",
+ pills: "Available",
days: "Days",
runsOut: "Runs Out",
},
@@ -234,6 +238,8 @@ const translations: Record = {
stockTitle: "MedAssist-ng: 1 Medication Running Critically Low",
stockTitleMultiple: "MedAssist-ng: {count} Medications Running Critically Low",
intakeTitle: "💊 Reminder: Medication intake in {minutes} min",
+ intakeTakenConfirmation: "✅ This dose was marked as taken.",
+ intakeSkippedConfirmation: "⏭️ This intake was marked as skipped.",
pillsLeft: "{count} pills",
daysLeft: "{count} days left",
pillsAt: "{count} pills at {time}",
@@ -301,6 +307,8 @@ const translations: Record = {
common: {
pill: "pill",
pills: "pills",
+ puffs: "puffs",
+ injections: "injections",
units: "units",
ml: "ml",
blister: "blister",
@@ -329,7 +337,7 @@ const translations: Record = {
descriptionLow: "Die folgenden Medikamente werden knapp und sollten bald nachbestellt werden:",
tableHeaders: {
medication: "Medikament",
- pills: "Tabletten",
+ pills: "Verfuegbar",
days: "Tage",
runsOut: "Aufgebraucht",
},
@@ -355,6 +363,8 @@ const translations: Record = {
stockTitle: "MedAssist-ng: 1 Medikament kritisch niedrig",
stockTitleMultiple: "MedAssist-ng: {count} Medikamente kritisch niedrig",
intakeTitle: "💊 Erinnerung: Medikamenteneinnahme in {minutes} Min.",
+ intakeTakenConfirmation: "✅ Diese Einnahme wurde als genommen markiert.",
+ intakeSkippedConfirmation: "⏭️ Diese Einnahme wurde als übersprungen markiert.",
pillsLeft: "{count} Tabletten",
daysLeft: "{count} Tage übrig",
pillsAt: "{count} Tabletten um {time}",
@@ -424,6 +434,8 @@ const translations: Record = {
common: {
pill: "Tablette",
pills: "Tabletten",
+ puffs: "Hübe",
+ injections: "Injektionen",
units: "Einheiten",
ml: "ml",
blister: "Blister",
diff --git a/backend/src/index.ts b/backend/src/index.ts
index ca7d522..07e3f06 100644
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -21,8 +21,10 @@ import { authRoutes } from "./routes/auth.js";
import { doseRoutes } from "./routes/doses.js";
import { exportRoutes } from "./routes/export.js";
import { healthRoutes } from "./routes/health.js";
+import { intakeJournalRoutes } from "./routes/intake-journal.js";
import { medicationEnrichmentRoutes } from "./routes/medication-enrichment.js";
import { medicationRoutes } from "./routes/medications.js";
+import { notificationActionRoutes } from "./routes/notification-actions.js";
import { oidcRoutes } from "./routes/oidc.js";
import { plannerRoutes } from "./routes/planner.js";
import { refillRoutes } from "./routes/refills.js";
@@ -79,6 +81,19 @@ function buildLoggerOptions(level: string) {
return base;
}
+function buildHelmetOptions(_isProduction: boolean) {
+ return {};
+}
+
+function isPublicNotificationActionPath(url: string | undefined): boolean {
+ if (!url) {
+ return false;
+ }
+
+ const normalizedUrl = url.split("?")[0]?.toLowerCase() ?? "";
+ return /(^|\/)(api\/)?notification-actions(\/|$)/.test(normalizedUrl);
+}
+
async function registerApiDocs(app: FastifyInstance, enabled: boolean) {
if (!enabled) return;
@@ -95,6 +110,7 @@ async function registerApiDocs(app: FastifyInstance, enabled: boolean) {
{ name: "health", description: "Service health endpoints" },
{ name: "auth", description: "Authentication and profile endpoints" },
{ name: "api-keys", description: "Programmatic API key management" },
+ { name: "intake-journal", description: "Owner-only intake journal CRUD and history endpoints" },
{ name: "medication-enrichment", description: "Medication search and enrichment endpoints" },
{ name: "settings", description: "User settings and notification test endpoints" },
],
@@ -166,6 +182,7 @@ export async function createApp(options?: {
app.addHook("onRequest", (request, reply, done) => {
request.correlationId = request.id;
reply.header("x-correlation-id", request.id);
+
done();
});
@@ -182,8 +199,26 @@ export async function createApp(options?: {
// Register plugins
await app.register(sensible);
- await app.register(helmet);
- await app.register(cors, { origin: opts.corsOrigins, credentials: true });
+ await app.register(helmet, buildHelmetOptions(opts.isProduction));
+ await app.register(cors, {
+ hook: "preHandler",
+ delegator: (request, callback) => {
+ if (isPublicNotificationActionPath(request.raw.url)) {
+ callback(null, {
+ origin: true,
+ credentials: false,
+ methods: ["GET", "HEAD", "POST", "OPTIONS"],
+ preflightContinue: true,
+ });
+ return;
+ }
+
+ callback(null, {
+ origin: opts.corsOrigins,
+ credentials: true,
+ });
+ },
+ });
await app.register(rateLimit, { max: 300, timeWindow: "1 minute" });
await app.register(cookie, { secret: opts.cookieSecret });
@@ -212,8 +247,10 @@ export async function createApp(options?: {
await app.register(medicationEnrichmentRoutes);
await app.register(settingsRoutes);
await app.register(plannerRoutes);
+ await app.register(notificationActionRoutes);
await app.register(shareRoutes);
await app.register(doseRoutes);
+ await app.register(intakeJournalRoutes);
await app.register(exportRoutes);
await app.register(refillRoutes);
await app.register(reportRoutes);
@@ -266,8 +303,26 @@ app.decorate("config", {
});
await app.register(sensible);
-await app.register(helmet);
-await app.register(cors, { origin: origins, credentials: true });
+await app.register(helmet, buildHelmetOptions(env.NODE_ENV === "production"));
+await app.register(cors, {
+ hook: "preHandler",
+ delegator: (request, callback) => {
+ if (isPublicNotificationActionPath(request.raw.url)) {
+ callback(null, {
+ origin: true,
+ credentials: false,
+ methods: ["GET", "HEAD", "POST", "OPTIONS"],
+ preflightContinue: true,
+ });
+ return;
+ }
+
+ callback(null, {
+ origin: origins,
+ credentials: true,
+ });
+ },
+});
await app.register(rateLimit, {
max: Number(process.env.RATE_LIMIT_MAX) || 100,
timeWindow: "1 minute",
@@ -294,8 +349,10 @@ await app.register(medicationRoutes);
await app.register(medicationEnrichmentRoutes);
await app.register(settingsRoutes);
await app.register(plannerRoutes);
+await app.register(notificationActionRoutes);
await app.register(shareRoutes);
await app.register(doseRoutes);
+await app.register(intakeJournalRoutes);
await app.register(exportRoutes);
await app.register(refillRoutes);
await app.register(reportRoutes);
@@ -309,6 +366,7 @@ const start = async () => {
startReminderScheduler({
info: (msg) => app.log.info(msg),
debug: (msg) => app.log.debug(msg),
+ warn: (msg) => app.log.warn(msg),
error: (msg) => app.log.error(msg),
});
@@ -323,6 +381,7 @@ const start = async () => {
startIntakeReminderScheduler({
info: (msg) => app.log.info(msg),
debug: (msg) => app.log.debug(msg),
+ warn: (msg) => app.log.warn(msg),
error: (msg) => app.log.error(msg),
});
} catch (err) {
diff --git a/backend/src/plugins/auth.ts b/backend/src/plugins/auth.ts
index 44e8435..d6ce3ef 100644
--- a/backend/src/plugins/auth.ts
+++ b/backend/src/plugins/auth.ts
@@ -136,7 +136,7 @@ async function tryApiKeyAuth(request: FastifyRequest, reply: FastifyReply): Prom
}
const [user] = await db.select().from(users).where(eq(users.id, keyRow.userId));
- if (!user || !user.isActive) {
+ if (!user?.isActive) {
reply.status(401).send({ error: "User not found", code: "USER_NOT_FOUND" });
throw new Error("USER_NOT_FOUND");
}
diff --git a/backend/src/plugins/env.ts b/backend/src/plugins/env.ts
index f8c4ffe..72c9c1d 100644
--- a/backend/src/plugins/env.ts
+++ b/backend/src/plugins/env.ts
@@ -10,10 +10,11 @@ const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
PORT: z
.string()
- .transform((v) => parseInt(v, 10))
- .default("3000"),
+ .default("3000")
+ .transform((v) => parseInt(v, 10)),
CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"),
LOG_LEVEL: z.string().default("info"),
+ PUBLIC_APP_URL: z.string().url().optional(),
OPENAPI_DOCS_ENABLED: z
.string()
.transform((v) => v === "true")
@@ -25,18 +26,18 @@ const EnvSchema = z.object({
// Master switch: Enable/disable authentication (default: disabled for easy setup)
AUTH_ENABLED: z
.string()
- .transform((v) => v === "true")
- .default("false"),
+ .default("false")
+ .transform((v) => v === "true"),
// Allow new user registrations (auto-enabled if no users exist)
REGISTRATION_ENABLED: z
.string()
- .transform((v) => v === "true")
- .default("false"),
+ .default("false")
+ .transform((v) => v === "true"),
// Disable username/password form login (useful for OIDC-only setups)
FORM_LOGIN_ENABLED: z
.string()
- .transform((v) => v === "true")
- .default("true"),
+ .default("true")
+ .transform((v) => v === "true"),
// JWT Secrets - only required when AUTH_ENABLED=true
JWT_SECRET: z.string().min(10).optional(),
@@ -46,20 +47,20 @@ const EnvSchema = z.object({
// Token TTL settings
ACCESS_TOKEN_TTL_MINUTES: z
.string()
- .transform((v) => parseInt(v, 10))
- .default("15"),
+ .default("15")
+ .transform((v) => parseInt(v, 10)),
REFRESH_TOKEN_TTL_DAYS: z
.string()
- .transform((v) => parseInt(v, 10))
- .default("7"),
+ .default("7")
+ .transform((v) => parseInt(v, 10)),
// ==========================================================================
// OIDC SSO Configuration (Pocket ID, Authelia, etc.)
// ==========================================================================
OIDC_ENABLED: z
.string()
- .transform((v) => v === "true")
- .default("false"),
+ .default("false")
+ .transform((v) => v === "true"),
OIDC_ISSUER_URL: z.string().url().optional(), // e.g., https://auth.example.com
OIDC_CLIENT_ID: z.string().optional(),
OIDC_CLIENT_SECRET: z.string().optional(),
@@ -67,8 +68,8 @@ const EnvSchema = z.object({
OIDC_SCOPES: z.string().default("openid profile email"),
OIDC_AUTO_CREATE_USERS: z
.string()
- .transform((v) => v === "true")
- .default("true"),
+ .default("true")
+ .transform((v) => v === "true"),
OIDC_USERNAME_CLAIM: z.string().default("preferred_username"), // or 'email', 'sub'
OIDC_PROVIDER_NAME: z.string().default("SSO"), // Display name for UI button
});
diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts
index 95114d6..eb6f796 100644
--- a/backend/src/routes/auth.ts
+++ b/backend/src/routes/auth.ts
@@ -221,7 +221,7 @@ export async function authRoutes(app: FastifyInstance) {
const parsed = registerSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
- error: parsed.error.errors[0]?.message ?? "Invalid input",
+ error: parsed.error.issues[0]?.message ?? "Invalid input",
code: "VALIDATION_ERROR",
});
}
@@ -438,7 +438,7 @@ export async function authRoutes(app: FastifyInstance) {
// Get user
const [user] = await db.select().from(users).where(eq(users.id, decoded.sub));
- if (!user || !user.isActive) {
+ if (!user?.isActive) {
return reply.status(401).send({ error: "User not found or disabled", code: "USER_INVALID" });
}
@@ -616,7 +616,7 @@ export async function authRoutes(app: FastifyInstance) {
const parsed = updateProfileSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
- error: parsed.error.errors[0]?.message ?? "Invalid input",
+ error: parsed.error.issues[0]?.message ?? "Invalid input",
code: "VALIDATION_ERROR",
});
}
diff --git a/backend/src/routes/doses.ts b/backend/src/routes/doses.ts
index f214a8e..fe3baf7 100644
--- a/backend/src/routes/doses.ts
+++ b/backend/src/routes/doses.ts
@@ -1,18 +1,26 @@
-import { and, eq } from "drizzle-orm";
+import { and, eq, inArray } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
-import { doseTracking, medications, shareTokens, userSettings } from "../db/schema.js";
+import { doseTracking, intakeJournal, medications, shareTokens, userSettings } from "../db/schema.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import { computeMedicationCurrentStock } from "../services/current-stock.js";
+import { markDoseTakenForUser } from "../services/dose-tracking-service.js";
+import {
+ getIntakeJournalForDoseEvent,
+ resolveTrackedDoseEventForUser,
+ upsertIntakeJournalForDoseEvent,
+} from "../services/intake-journal-service.js";
import type { AuthUser } from "../types/fastify.js";
+import { toLocalDateTimeOffsetString } from "../utils/local-date-time.js";
import {
applyOpenApiRouteStandards,
genericErrorSchema,
tokenParamsSchema,
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
+import { redactTokenForLog } from "../utils/redaction.js";
import {
parseIntakesJson,
parseLocalDateTime,
@@ -31,6 +39,10 @@ const shareDoseSchema = z.object({
doseId: z.string().min(1, "doseId is required"),
});
+const shareJournalUpsertSchema = z.object({
+ note: z.string().max(4000),
+});
+
const dismissDosesSchema = z.object({
doseIds: z.array(z.string().min(1)).min(1, "At least one doseId is required"),
});
@@ -55,12 +67,73 @@ const doseReadResponseSchema = {
markedBy: { type: ["string", "null"] },
takenSource: { type: "string" },
dismissed: { type: "boolean" },
+ hasJournalNote: { type: "boolean" },
},
},
},
},
} as const;
+const shareJournalEntrySchema = {
+ type: "object",
+ required: [
+ "doseTrackingId",
+ "doseId",
+ "medicationId",
+ "medicationName",
+ "scheduledFor",
+ "dismissed",
+ "takenSource",
+ "note",
+ "updatedAt",
+ ],
+ properties: {
+ doseTrackingId: { type: "integer" },
+ doseId: { type: "string" },
+ medicationId: { type: "integer" },
+ medicationName: { type: "string" },
+ scheduledFor: { type: "string", format: "date-time" },
+ takenAt: { type: ["string", "null"], format: "date-time" },
+ dismissed: { type: "boolean" },
+ takenSource: { type: "string", enum: ["manual", "automatic"] },
+ markedBy: { type: ["string", "null"] },
+ note: { type: ["string", "null"] },
+ updatedAt: { type: ["string", "null"], format: "date-time" },
+ createdAt: { type: ["string", "null"], format: "date-time" },
+ },
+ additionalProperties: false,
+} as const;
+
+const shareJournalResponseSchema = {
+ type: "object",
+ required: ["entry"],
+ properties: {
+ entry: shareJournalEntrySchema,
+ },
+ additionalProperties: false,
+} as const;
+
+function getValidationErrorMessage(error: z.ZodError): string {
+ const firstIssue = error.issues[0];
+ if (!firstIssue) {
+ return "Invalid input";
+ }
+
+ return firstIssue.code === "invalid_type" && firstIssue.input === undefined ? "Required" : firstIssue.message;
+}
+
+function serializeJournalTakenAt(value: Date | null, dismissed: boolean): string | null {
+ if (!(value instanceof Date) || Number.isNaN(value.getTime())) {
+ return null;
+ }
+
+ if (dismissed && value.getTime() <= 0) {
+ return null;
+ }
+
+ return value.toISOString();
+}
+
// Helper to get user ID from request
// Returns anonymous user ID when auth is disabled
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise {
@@ -125,6 +198,10 @@ async function validateShareDoseId(share: typeof shareTokens.$inferSelect, doseI
return false;
}
+ if (!isDoseInsideShareScheduleWindow(share, parsedDose)) {
+ return false;
+ }
+
const [medication] = await db
.select()
.from(medications)
@@ -162,6 +239,24 @@ async function validateShareDoseId(share: typeof shareTokens.$inferSelect, doseI
return expectedPersons.includes(parsedDose.personSuffix);
}
+function getLocalDayStartMs(value: Date | number): number {
+ const date = typeof value === "number" ? new Date(value) : new Date(value.getTime());
+ date.setHours(0, 0, 0, 0);
+ return date.getTime();
+}
+
+function isDoseInsideShareScheduleWindow(share: typeof shareTokens.$inferSelect, parsedDose: ParsedDoseId): boolean {
+ const scheduleDays = Math.max(1, share.scheduleDays ?? 30);
+ const todayStart = getLocalDayStartMs(new Date());
+ const earliestVisible = new Date(todayStart);
+ earliestVisible.setDate(earliestVisible.getDate() - (scheduleDays - 1));
+ const latestVisibleExclusive = new Date(todayStart);
+ latestVisibleExclusive.setDate(latestVisibleExclusive.getDate() + scheduleDays);
+ const doseDayStart = getLocalDayStartMs(parsedDose.timestampMs);
+
+ return doseDayStart >= earliestVisible.getTime() && doseDayStart < latestVisibleExclusive.getTime();
+}
+
async function isDoseOutOfStock(options: {
userId: number;
doseId: string;
@@ -216,6 +311,81 @@ async function isDoseOutOfStock(options: {
);
}
+async function markDoseSkippedForUser(input: {
+ userId: number;
+ doseId: string;
+}): Promise<"created" | "updated" | "already_skipped"> {
+ const [existing] = await db
+ .select()
+ .from(doseTracking)
+ .where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, input.doseId)));
+
+ if (existing) {
+ if (existing.dismissed) {
+ return "already_skipped";
+ }
+
+ await db
+ .update(doseTracking)
+ .set({ dismissed: true })
+ .where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, input.doseId)));
+ return "updated";
+ }
+
+ await db.insert(doseTracking).values({
+ userId: input.userId,
+ doseId: input.doseId,
+ markedBy: null,
+ takenAt: new Date(0),
+ dismissed: true,
+ });
+
+ return "created";
+}
+
+async function undoDoseSkippedForUser(input: { userId: number; doseId: string }): Promise {
+ const [existing] = await db
+ .select()
+ .from(doseTracking)
+ .where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, input.doseId)));
+
+ if (!existing?.dismissed) {
+ return false;
+ }
+
+ const hasRealTakenTimestamp =
+ existing.takenAt instanceof Date ? existing.takenAt.getTime() > 0 : Boolean(existing.takenAt);
+ if (existing.markedBy !== null || hasRealTakenTimestamp) {
+ await db.update(doseTracking).set({ dismissed: false }).where(eq(doseTracking.id, existing.id));
+ return true;
+ }
+
+ await db.delete(doseTracking).where(eq(doseTracking.id, existing.id));
+ return true;
+}
+
+function buildSharedJournalEntryDto(input: {
+ event: NonNullable>>;
+ journalEntry: Awaited>;
+}) {
+ const { event, journalEntry } = input;
+
+ return {
+ doseTrackingId: event.doseTrackingId,
+ doseId: event.doseId,
+ medicationId: event.medicationId,
+ medicationName: event.medicationName,
+ scheduledFor: toLocalDateTimeOffsetString(journalEntry?.scheduledFor ?? event.scheduledFor),
+ takenAt: serializeJournalTakenAt(event.takenAt, event.dismissed),
+ dismissed: event.dismissed,
+ takenSource: event.takenSource,
+ markedBy: event.markedBy,
+ note: journalEntry?.note ?? null,
+ updatedAt: journalEntry?.updatedAt?.toISOString() ?? null,
+ createdAt: journalEntry?.createdAt?.toISOString() ?? null,
+ };
+}
+
// =============================================================================
// Dose Tracking Routes
// =============================================================================
@@ -223,7 +393,13 @@ export async function doseRoutes(app: FastifyInstance) {
applyOpenApiRouteStandards(app, {
tag: "doses",
protectedByDefault: false,
- protectedPaths: [/^\/doses\/taken$/, /^\/doses\/taken\/:doseId$/, /^\/doses\/dismiss$/],
+ protectedPaths: [
+ /^\/doses\/taken$/,
+ /^\/doses\/taken\/:doseId$/,
+ /^\/doses\/dismiss$/,
+ /^\/doses\/skip$/,
+ /^\/doses\/skip\/:doseId$/,
+ ],
});
// ---------------------------------------------------------------------------
@@ -301,40 +477,28 @@ export async function doseRoutes(app: FastifyInstance) {
const parsed = markDoseSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
- error: parsed.error.errors[0]?.message ?? "Invalid input",
+ error: getValidationErrorMessage(parsed.error),
});
}
const { doseId } = parsed.data;
- // Check if already marked
- const [existing] = await db
- .select()
- .from(doseTracking)
- .where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId)));
+ const result = await markDoseTakenForUser({
+ userId,
+ doseId,
+ source: "manual",
+ markedBy: null,
+ });
- if (existing) {
+ if (!result.success) {
+ const statusCode = result.code === "INVALID_DOSE" ? 400 : 409;
+ return reply.status(statusCode).send({ error: result.message, code: result.code });
+ }
+
+ if (result.status === "already_taken") {
return { success: true, message: "Already marked" };
}
- const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
- const outOfStock = await isDoseOutOfStock({
- userId,
- doseId,
- stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic",
- });
- if (outOfStock) {
- return reply.status(409).send({ error: "Medication is out of stock", code: "OUT_OF_STOCK" });
- }
-
- // Insert new record
- await db.insert(doseTracking).values({
- userId,
- doseId,
- markedBy: null, // Marked by the user themselves
- takenSource: "manual",
- });
-
return { success: true };
}
);
@@ -385,6 +549,83 @@ export async function doseRoutes(app: FastifyInstance) {
}
);
+ // ---------------------------------------------------------------------------
+ // POST /doses/skip - PROTECTED: Mark a single dose as skipped
+ // ---------------------------------------------------------------------------
+ app.post<{ Body: z.infer }>(
+ "/doses/skip",
+ {
+ preHandler: requireAuth,
+ schema: {
+ tags: ["doses"],
+ security: protectedEndpointSecurity,
+ body: {
+ type: "object",
+ required: ["doseId"],
+ properties: {
+ doseId: { type: "string", minLength: 1 },
+ },
+ },
+ response: {
+ 200: {
+ type: "object",
+ properties: {
+ success: { type: "boolean" },
+ message: { type: "string" },
+ },
+ },
+ 400: { anyOf: [genericErrorSchema, validationErrorSchema] },
+ 401: genericErrorSchema,
+ },
+ },
+ },
+ async (request, reply) => {
+ const userId = await getUserId(request, reply);
+ const parsed = markDoseSchema.safeParse(request.body);
+ if (!parsed.success) {
+ return reply.status(400).send({ error: getValidationErrorMessage(parsed.error) });
+ }
+
+ const status = await markDoseSkippedForUser({ userId, doseId: parsed.data.doseId });
+ if (status === "already_skipped") {
+ return { success: true, message: "Already skipped" };
+ }
+
+ return { success: true };
+ }
+ );
+
+ // ---------------------------------------------------------------------------
+ // DELETE /doses/skip/:doseId - PROTECTED: Undo a single skipped dose
+ // ---------------------------------------------------------------------------
+ app.delete<{ Params: { doseId: string } }>(
+ "/doses/skip/:doseId",
+ {
+ preHandler: requireAuth,
+ schema: {
+ tags: ["doses"],
+ security: protectedEndpointSecurity,
+ params: {
+ type: "object",
+ required: ["doseId"],
+ properties: {
+ doseId: { type: "string", minLength: 1 },
+ },
+ },
+ response: {
+ 200: { type: "object", properties: { success: { type: "boolean" } } },
+ 401: genericErrorSchema,
+ },
+ },
+ },
+ async (request, reply) => {
+ const userId = await getUserId(request, reply);
+ await undoDoseSkippedForUser({ userId, doseId: request.params.doseId });
+
+ return { success: true };
+ }
+ );
+
// ---------------------------------------------------------------------------
// POST /doses/dismiss - PROTECTED: Dismiss missed doses without deducting stock
// ---------------------------------------------------------------------------
@@ -423,39 +664,18 @@ export async function doseRoutes(app: FastifyInstance) {
const parsed = dismissDosesSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
- error: parsed.error.errors[0]?.message ?? "Invalid input",
+ error: getValidationErrorMessage(parsed.error),
});
}
const { doseIds } = parsed.data;
- // Insert dismissed records for each dose that doesn't exist yet
+ // Preserve the existing route semantics for dismiss: any non-dismissed record
+ // becomes dismissed, regardless of whether it already has a taken timestamp.
let dismissedCount = 0;
for (const doseId of doseIds) {
- // Check if already exists (taken or dismissed)
- const [existing] = await db
- .select()
- .from(doseTracking)
- .where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId)));
-
- if (existing) {
- // Already exists - update to dismissed if not already
- if (!existing.dismissed) {
- await db
- .update(doseTracking)
- .set({ dismissed: true })
- .where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId)));
- dismissedCount++;
- }
- } else {
- // Create new dismissed record
- await db.insert(doseTracking).values({
- userId,
- doseId,
- markedBy: null,
- takenAt: new Date(0),
- dismissed: true,
- });
+ const status = await markDoseSkippedForUser({ userId, doseId });
+ if (status !== "already_skipped") {
dismissedCount++;
}
}
@@ -537,28 +757,332 @@ export async function doseRoutes(app: FastifyInstance) {
},
async (request, reply) => {
const { token } = request.params;
+ const tokenRef = redactTokenForLog(token);
const { share, reason } = await getActiveShareToken(token);
if (!share) {
- request.log.warn(`[ShareDose] Rejected read: token=${token}, reason=${reason}`);
+ request.log.warn(`[ShareDose] Rejected read: tokenRef=${tokenRef}, reason=${reason}`);
return reply.notFound("Share link not found");
}
- // Get all taken doses for this user (no time limit)
+ // Keep public dose reads scoped to the selected share person and visible schedule window.
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId));
+ const visibleDoses: (typeof doseTracking.$inferSelect)[] = [];
+ for (const dose of doses) {
+ if (await validateShareDoseId(share, dose.doseId)) {
+ visibleDoses.push(dose);
+ }
+ }
+
+ const journalDoseTrackingIds = new Set();
+ if ((share.allowJournalNotes ?? false) && visibleDoses.length > 0) {
+ const journalRows = await db
+ .select({ doseTrackingId: intakeJournal.doseTrackingId })
+ .from(intakeJournal)
+ .where(
+ and(
+ eq(intakeJournal.userId, share.userId),
+ inArray(
+ intakeJournal.doseTrackingId,
+ visibleDoses.map((dose) => dose.id)
+ )
+ )
+ );
+
+ for (const row of journalRows) {
+ journalDoseTrackingIds.add(row.doseTrackingId);
+ }
+ }
return {
- doses: doses.map((d) => ({
+ doses: visibleDoses.map((d) => ({
doseId: d.doseId,
takenAt: d.takenAt?.getTime() ?? Date.now(),
markedBy: d.markedBy,
takenSource: d.takenSource ?? "manual",
dismissed: d.dismissed ?? false,
+ hasJournalNote: journalDoseTrackingIds.has(d.id),
})),
};
}
);
+ app.get<{ Params: { token: string; doseId: string } }>(
+ "/share/:token/journal/event/:doseId",
+ {
+ schema: {
+ params: {
+ type: "object",
+ required: ["token", "doseId"],
+ properties: {
+ token: tokenParamsSchema.properties.token,
+ doseId: { type: "string", minLength: 1 },
+ },
+ },
+ response: {
+ 200: shareJournalResponseSchema,
+ 400: { anyOf: [genericErrorSchema, validationErrorSchema] },
+ 403: genericErrorSchema,
+ 404: genericErrorSchema,
+ },
+ },
+ },
+ async (request, reply) => {
+ const { token, doseId } = request.params;
+ const tokenRef = redactTokenForLog(token);
+
+ const { share, reason } = await getActiveShareToken(token);
+ if (!share) {
+ request.log.warn(`[ShareJournal] Rejected read: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
+ return reply.notFound("Share link not found");
+ }
+
+ if (!(share.allowJournalNotes ?? false)) {
+ return reply
+ .status(403)
+ .send({ error: "Journal notes are not enabled for this share link", code: "NOT_ENABLED" });
+ }
+
+ const isValidShareDoseId = await validateShareDoseId(share, doseId);
+ if (!isValidShareDoseId) {
+ return reply.status(400).send({ error: "Invalid or unauthorized doseId", code: "INVALID_DOSE" });
+ }
+
+ const event = await resolveTrackedDoseEventForUser({ userId: share.userId, doseId });
+ if (!event) {
+ return reply
+ .status(404)
+ .send({ error: "Tracked dose event not found for this share link", code: "DOSE_NOT_FOUND" });
+ }
+
+ const journalEntry = await getIntakeJournalForDoseEvent({ userId: share.userId, doseId });
+ return { entry: buildSharedJournalEntryDto({ event, journalEntry }) };
+ }
+ );
+
+ app.put<{ Params: { token: string; doseId: string }; Body: z.infer }>(
+ "/share/:token/journal/event/:doseId",
+ {
+ schema: {
+ params: {
+ type: "object",
+ required: ["token", "doseId"],
+ properties: {
+ token: tokenParamsSchema.properties.token,
+ doseId: { type: "string", minLength: 1 },
+ },
+ },
+ body: {
+ type: "object",
+ required: ["note"],
+ properties: {
+ note: { type: "string", maxLength: 4000 },
+ },
+ additionalProperties: false,
+ },
+ response: {
+ 200: shareJournalResponseSchema,
+ 400: { anyOf: [genericErrorSchema, validationErrorSchema] },
+ 403: genericErrorSchema,
+ 404: genericErrorSchema,
+ },
+ },
+ },
+ async (request, reply) => {
+ const { token, doseId } = request.params;
+ const tokenRef = redactTokenForLog(token);
+
+ const parsed = shareJournalUpsertSchema.safeParse(request.body);
+ if (!parsed.success) {
+ return reply.status(400).send({ error: getValidationErrorMessage(parsed.error), code: "VALIDATION_ERROR" });
+ }
+
+ const normalizedNote = parsed.data.note.trim();
+ if (normalizedNote.length === 0) {
+ return reply.status(400).send({ error: "Journal note cannot be empty", code: "EMPTY_NOTE" });
+ }
+
+ const { share, reason } = await getActiveShareToken(token);
+ if (!share) {
+ request.log.warn(`[ShareJournal] Rejected save: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
+ return reply.notFound("Share link not found");
+ }
+
+ if (!(share.allowJournalNotes ?? false)) {
+ return reply
+ .status(403)
+ .send({ error: "Journal notes are not enabled for this share link", code: "NOT_ENABLED" });
+ }
+
+ const isValidShareDoseId = await validateShareDoseId(share, doseId);
+ if (!isValidShareDoseId) {
+ return reply.status(400).send({ error: "Invalid or unauthorized doseId", code: "INVALID_DOSE" });
+ }
+
+ const event = await resolveTrackedDoseEventForUser({ userId: share.userId, doseId });
+ if (!event) {
+ return reply
+ .status(404)
+ .send({ error: "Tracked dose event not found for this share link", code: "DOSE_NOT_FOUND" });
+ }
+
+ const journalEntry = await upsertIntakeJournalForDoseEvent({
+ userId: share.userId,
+ doseId,
+ note: normalizedNote,
+ });
+
+ return { entry: buildSharedJournalEntryDto({ event, journalEntry }) };
+ }
+ );
+
+ app.delete<{ Params: { token: string; doseId: string } }>(
+ "/share/:token/journal/event/:doseId",
+ {
+ schema: {
+ params: {
+ type: "object",
+ required: ["token", "doseId"],
+ properties: {
+ token: tokenParamsSchema.properties.token,
+ doseId: { type: "string", minLength: 1 },
+ },
+ },
+ response: {
+ 403: genericErrorSchema,
+ 404: genericErrorSchema,
+ },
+ },
+ },
+ async (request, reply) => {
+ const { token, doseId } = request.params;
+ const tokenRef = redactTokenForLog(token);
+
+ const { share, reason } = await getActiveShareToken(token);
+ if (!share) {
+ request.log.warn(`[ShareJournal] Rejected delete: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
+ return reply.notFound("Share link not found");
+ }
+
+ if (!(share.allowJournalNotes ?? false)) {
+ return reply
+ .status(403)
+ .send({ error: "Journal notes are not enabled for this share link", code: "NOT_ENABLED" });
+ }
+
+ return reply.status(403).send({ error: "Shared links cannot delete journal notes", code: "DELETE_NOT_ALLOWED" });
+ }
+ );
+
+ // ---------------------------------------------------------------------------
+ // POST /share/:token/doses/skip - PUBLIC: Mark a dose as skipped via share link
+ // ---------------------------------------------------------------------------
+ app.post<{ Params: { token: string }; Body: z.infer }>(
+ "/share/:token/doses/skip",
+ {
+ schema: {
+ params: tokenParamsSchema,
+ body: {
+ type: "object",
+ required: ["doseId"],
+ properties: {
+ doseId: { type: "string", minLength: 1 },
+ },
+ },
+ response: {
+ 200: {
+ type: "object",
+ properties: {
+ success: { type: "boolean" },
+ message: { type: "string" },
+ },
+ },
+ 400: { anyOf: [genericErrorSchema, validationErrorSchema] },
+ 404: genericErrorSchema,
+ },
+ },
+ },
+ async (request, reply) => {
+ const { token } = request.params;
+ const tokenRef = redactTokenForLog(token);
+
+ const parsed = shareDoseSchema.safeParse(request.body);
+ if (!parsed.success) {
+ return reply.status(400).send({ error: getValidationErrorMessage(parsed.error) });
+ }
+
+ const { doseId } = parsed.data;
+ const { share, reason } = await getActiveShareToken(token);
+ if (!share) {
+ request.log.warn(`[ShareDose] Rejected skip: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
+ return reply.notFound("Share link not found");
+ }
+
+ const isValidShareDoseId = await validateShareDoseId(share, doseId);
+ if (!isValidShareDoseId) {
+ request.log.warn(
+ `[ShareDose] Rejected invalid doseId in skip request: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
+ );
+ return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
+ }
+
+ const status = await markDoseSkippedForUser({ userId: share.userId, doseId });
+ if (status === "already_skipped") {
+ return { success: true, message: "Already skipped" };
+ }
+
+ request.log.info(
+ `[ShareDose] Dose skipped via share link: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
+ );
+ return { success: true };
+ }
+ );
+
+ // ---------------------------------------------------------------------------
+ // DELETE /share/:token/doses/skip/:doseId - PUBLIC: Undo a skipped dose via share link
+ // ---------------------------------------------------------------------------
+ app.delete<{ Params: { token: string; doseId: string } }>(
+ "/share/:token/doses/skip/:doseId",
+ {
+ schema: {
+ params: {
+ type: "object",
+ required: ["token", "doseId"],
+ properties: {
+ token: tokenParamsSchema.properties.token,
+ doseId: { type: "string", minLength: 1 },
+ },
+ },
+ response: {
+ 200: { type: "object", properties: { success: { type: "boolean" } } },
+ 400: genericErrorSchema,
+ 404: genericErrorSchema,
+ },
+ },
+ },
+ async (request, reply) => {
+ const { token, doseId } = request.params;
+ const tokenRef = redactTokenForLog(token);
+
+ const { share, reason } = await getActiveShareToken(token);
+ if (!share) {
+ request.log.warn(`[ShareDose] Rejected undo skip: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
+ return reply.notFound("Share link not found");
+ }
+
+ const isValidShareDoseId = await validateShareDoseId(share, doseId);
+ if (!isValidShareDoseId) {
+ request.log.warn(
+ `[ShareDose] Rejected invalid doseId in undo skip request: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
+ );
+ return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
+ }
+
+ await undoDoseSkippedForUser({ userId: share.userId, doseId });
+ return { success: true };
+ }
+ );
+
// ---------------------------------------------------------------------------
// POST /share/:token/doses - PUBLIC: Mark a dose as taken via share link
// ---------------------------------------------------------------------------
@@ -586,11 +1110,12 @@ export async function doseRoutes(app: FastifyInstance) {
},
async (request, reply) => {
const { token } = request.params;
+ const tokenRef = redactTokenForLog(token);
const parsed = shareDoseSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
- error: parsed.error.errors[0]?.message ?? "Invalid input",
+ error: getValidationErrorMessage(parsed.error),
});
}
@@ -598,14 +1123,14 @@ export async function doseRoutes(app: FastifyInstance) {
const { share, reason } = await getActiveShareToken(token);
if (!share) {
- request.log.warn(`[ShareDose] Rejected mark: token=${token}, doseId=${doseId}, reason=${reason}`);
+ request.log.warn(`[ShareDose] Rejected mark: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
return reply.notFound("Share link not found");
}
const isValidShareDoseId = await validateShareDoseId(share, doseId);
if (!isValidShareDoseId) {
request.log.warn(
- `[ShareDose] Rejected invalid doseId in mark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
+ `[ShareDose] Rejected invalid doseId in mark request: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
}
@@ -618,7 +1143,7 @@ export async function doseRoutes(app: FastifyInstance) {
if (existing) {
request.log.debug(
- `[ShareDose] Duplicate mark ignored: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
+ `[ShareDose] Duplicate mark ignored: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
return { success: true, message: "Already marked" };
}
@@ -631,7 +1156,7 @@ export async function doseRoutes(app: FastifyInstance) {
});
if (outOfStock) {
request.log.info(
- `[ShareDose] Rejected out-of-stock mark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
+ `[ShareDose] Rejected out-of-stock mark request: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
return reply.status(409).send({ error: "Medication is out of stock", code: "OUT_OF_STOCK" });
}
@@ -648,7 +1173,7 @@ export async function doseRoutes(app: FastifyInstance) {
});
request.log.info(
- `[ShareDose] Dose marked via share link: token=${token}, ownerUserId=${share.userId}, shareTakenBy=${share.takenBy}, markedBy=${markedBy}, doseId=${doseId}`
+ `[ShareDose] Dose marked via share link: tokenRef=${tokenRef}, ownerUserId=${share.userId}, shareTakenBy=${share.takenBy}, markedBy=${markedBy}, doseId=${doseId}`
);
return { success: true };
@@ -679,17 +1204,18 @@ export async function doseRoutes(app: FastifyInstance) {
},
async (request, reply) => {
const { token, doseId } = request.params;
+ const tokenRef = redactTokenForLog(token);
const { share, reason } = await getActiveShareToken(token);
if (!share) {
- request.log.warn(`[ShareDose] Rejected unmark: token=${token}, doseId=${doseId}, reason=${reason}`);
+ request.log.warn(`[ShareDose] Rejected unmark: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
return reply.notFound("Share link not found");
}
const isValidShareDoseId = await validateShareDoseId(share, doseId);
if (!isValidShareDoseId) {
request.log.warn(
- `[ShareDose] Rejected invalid doseId in unmark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
+ `[ShareDose] Rejected invalid doseId in unmark request: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
}
@@ -703,7 +1229,7 @@ export async function doseRoutes(app: FastifyInstance) {
if (existing?.dismissed) {
// Already dismissed - keep the record as-is
request.log.debug(
- `[ShareDose] Unmark ignored for dismissed dose: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
+ `[ShareDose] Unmark ignored for dismissed dose: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
} else {
// Not dismissed - delete the record entirely
@@ -711,7 +1237,7 @@ export async function doseRoutes(app: FastifyInstance) {
.delete(doseTracking)
.where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
request.log.info(
- `[ShareDose] Dose unmarked via share link: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
+ `[ShareDose] Dose unmarked via share link: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
}
diff --git a/backend/src/routes/export.ts b/backend/src/routes/export.ts
index 4fced15..c0e0330 100644
--- a/backend/src/routes/export.ts
+++ b/backend/src/routes/export.ts
@@ -6,9 +6,13 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
import { getDataDir } from "../db/path-utils.js";
-import { doseTracking, medications, refillHistory, shareTokens, userSettings } from "../db/schema.js";
+import { doseTracking, intakeJournal, medications, refillHistory, shareTokens, userSettings } from "../db/schema.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
+import {
+ listIntakeJournalExportPayloadsForUser,
+ restoreIntakeJournalForImportedDose,
+} from "../services/intake-journal-export.js";
import type { AuthUser } from "../types/fastify.js";
import {
applyOpenApiRouteStandards,
@@ -23,7 +27,7 @@ const IMAGES_DIR = resolve(getDataDir(), "images");
// =============================================================================
// Export Format Version (bump this when format changes)
// =============================================================================
-const EXPORT_VERSION = "1.4";
+const EXPORT_VERSION = "1.6";
// =============================================================================
// Zod Schemas for Import Validation
@@ -62,7 +66,7 @@ const medicationExportSchema = z.object({
lifecycleCategory: z.enum(["refill_when_empty", "treatment_period"]).default("refill_when_empty"),
inventory: inventorySchema,
pillWeightMg: z.number().int().nullable().optional(),
- doseUnit: z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg"),
+ doseUnit: z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs", "injections"]).default("mg"),
schedules: z.array(scheduleSchema).default([]),
medicationStartDate: z.string().nullable().optional(),
medicationEndDate: z.string().nullable().optional(),
@@ -91,12 +95,16 @@ const doseHistorySchema = z.object({
takenSource: z.enum(["manual", "automatic"]).default("manual"),
dismissed: z.boolean().default(false),
takenByPerson: z.string().nullable().optional(), // Person suffix from dose ID (e.g., "Daniel")
+ journalNote: z.string().nullable().optional(),
+ journalCreatedAt: z.string().nullable().optional(),
+ journalUpdatedAt: z.string().nullable().optional(),
});
const refillHistoryExportSchema = z.object({
medicationRef: z.string(), // References _exportId
packsAdded: z.number().int().min(0).default(0),
- loosePillsAdded: z.number().int().min(0).default(0),
+ loosePillsAdded: z.number().int().min(0).optional(),
+ quantityAdded: z.number().int().min(0).optional(),
usedPrescription: z.boolean().default(false),
refillDate: z.string(), // ISO datetime
});
@@ -104,41 +112,47 @@ const refillHistoryExportSchema = z.object({
const shareLinkSchema = z.object({
takenBy: z.string().min(1),
scheduleDays: z.number().int().min(1).default(30),
+ allowJournalNotes: z.boolean().default(false),
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),
- emailPrescriptionReminders: 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),
- shoutrrrPrescriptionReminders: 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),
- expiryWarningDays: z.number().int().default(90),
- // UI preferences
- language: z.string().default("en"),
- stockCalculationMode: z.enum(["automatic", "manual"]).default("automatic"),
- shareStockStatus: z.boolean().default(true),
- shareMedicationOverview: z.boolean().default(false),
+const settingsSchemaBase = z.object({
+ // Email notifications
+ emailEnabled: z.boolean().default(false),
+ notificationEmail: z.string().nullable().optional(),
+ emailStockReminders: z.boolean().default(true),
+ emailIntakeReminders: z.boolean().default(true),
+ emailPrescriptionReminders: 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),
+ shoutrrrPrescriptionReminders: 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),
+ expiryWarningDays: z.number().int().default(90),
+ // UI preferences
+ language: z.string().default("en"),
+ stockCalculationMode: z.enum(["automatic", "manual"]).default("automatic"),
+ shareMedicationOverview: z.boolean().default(false),
+});
+
+const importSettingsSchema = settingsSchemaBase
+ .extend({
+ // Accept the removed field from legacy exports so old backups still import,
+ // but do not map it back into current runtime settings.
+ shareStockStatus: z.boolean().optional(),
})
.optional();
@@ -149,7 +163,7 @@ const importDataSchema = z.object({
medications: z.array(medicationExportSchema).default([]),
doseHistory: z.array(doseHistorySchema).default([]),
refillHistory: z.array(refillHistoryExportSchema).default([]),
- settings: settingsExportSchema,
+ settings: importSettingsSchema,
shareLinks: z.array(shareLinkSchema).default([]),
});
@@ -189,7 +203,7 @@ const importBodyOpenApiSchema = {
shareLinks: { type: "array", items: { type: "object", additionalProperties: true } },
},
example: {
- version: "1.8.0",
+ version: "1.6",
exportedAt: "2026-03-11T10:15:00.000Z",
includeSensitiveData: true,
medications: [
@@ -209,13 +223,72 @@ const importBodyOpenApiSchema = {
],
},
],
- doseHistory: [{ doseId: "1:2026-03-11T08:00:00.000Z:Daniel", takenAt: 1773216000000 }],
- refillHistory: [{ packsAdded: 1, loosePillsAdded: 4, refillDate: "2026-03-10T12:00:00.000Z" }],
+ doseHistory: [
+ {
+ medicationRef: "med-1",
+ scheduleIndex: 0,
+ scheduledTime: "2026-03-11T08:00:00.000Z",
+ takenAt: "2026-03-11T08:03:00.000Z",
+ markedBy: "Daniel",
+ takenSource: "manual",
+ dismissed: false,
+ takenByPerson: "Daniel",
+ journalNote: "Took after breakfast.",
+ journalUpdatedAt: "2026-03-11T08:05:00.000Z",
+ },
+ ],
+ refillHistory: [{ packsAdded: 1, loosePillsAdded: 4, quantityAdded: 34, refillDate: "2026-03-10T12:00:00.000Z" }],
settings: { language: "en", stockCalculationMode: "automatic" },
shareLinks: [{ takenBy: "Daniel", scheduleDays: 14 }],
},
} as const;
+const importPreviewResponseSchema = {
+ type: "object",
+ properties: {
+ success: { type: "boolean" },
+ preview: {
+ type: "object",
+ properties: {
+ version: { type: "string" },
+ exportedAt: { type: "string", format: "date-time" },
+ includeSensitiveData: { type: "boolean" },
+ incoming: {
+ type: "object",
+ properties: {
+ medications: { type: "integer" },
+ doseHistory: { type: "integer" },
+ refillHistory: { type: "integer" },
+ shareLinks: { type: "integer" },
+ journalEntries: { type: "integer" },
+ imageCount: { type: "integer" },
+ hasSettings: { type: "boolean" },
+ },
+ },
+ current: {
+ type: "object",
+ properties: {
+ medications: { type: "integer" },
+ doseHistory: { type: "integer" },
+ refillHistory: { type: "integer" },
+ shareLinks: { type: "integer" },
+ hasSettings: { type: "boolean" },
+ },
+ },
+ warnings: {
+ type: "object",
+ properties: {
+ replacesExistingData: { type: "boolean" },
+ regeneratesShareLinks: { type: "boolean" },
+ containsImages: { type: "boolean" },
+ containsSensitiveData: { type: "boolean" },
+ },
+ },
+ },
+ },
+ },
+} as const;
+
// =============================================================================
// Helper Functions
// =============================================================================
@@ -289,7 +362,7 @@ function imageToBase64(imageUrl: string | null): string | null {
// Save base64 image to file and return filename
function base64ToImage(base64: string, medicationId: number): string | null {
- if (!base64 || !base64.startsWith("data:")) return null;
+ if (!base64.startsWith("data:")) return null;
try {
// Parse data URL: "data:image/jpeg;base64,/9j/4AAQ..."
@@ -315,6 +388,64 @@ function base64ToImage(base64: string, medicationId: number): string | null {
}
}
+function removeFileIfPresent(filePath: string): string | null {
+ if (!existsSync(filePath)) {
+ return null;
+ }
+
+ try {
+ unlinkSync(filePath);
+ return null;
+ } catch (error) {
+ return error instanceof Error ? error.message : "Unknown file removal error";
+ }
+}
+
+function buildImportPreview(
+ importData: z.infer,
+ currentData: {
+ medications: number;
+ doseHistory: number;
+ refillHistory: number;
+ shareLinks: number;
+ hasSettings: boolean;
+ }
+) {
+ const journalEntries = importData.doseHistory.filter(
+ (dose) => typeof dose.journalNote === "string" && dose.journalNote.trim()
+ ).length;
+ const imageCount = importData.medications.filter(
+ (med) => typeof med.image === "string" && med.image.startsWith("data:")
+ ).length;
+
+ return {
+ version: importData.version,
+ exportedAt: importData.exportedAt,
+ includeSensitiveData: importData.includeSensitiveData,
+ incoming: {
+ medications: importData.medications.length,
+ doseHistory: importData.doseHistory.length,
+ refillHistory: importData.refillHistory.length,
+ shareLinks: importData.shareLinks.length,
+ journalEntries,
+ imageCount,
+ hasSettings: Boolean(importData.settings),
+ },
+ current: currentData,
+ warnings: {
+ replacesExistingData:
+ currentData.medications > 0 ||
+ currentData.doseHistory > 0 ||
+ currentData.refillHistory > 0 ||
+ currentData.shareLinks > 0 ||
+ currentData.hasSettings,
+ regeneratesShareLinks: importData.shareLinks.length > 0,
+ containsImages: imageCount > 0,
+ containsSensitiveData: importData.includeSensitiveData,
+ },
+ };
+}
+
// Parse dose ID to extract medication ID and timestamp
// Format: "{medicationId}-{blisterIndex}-{timestampMs}" or "{medicationId}-{blisterIndex}-{timestampMs}-{person}"
function parseDoseId(
@@ -370,6 +501,7 @@ export async function exportRoutes(app: FastifyInstance) {
// 1. Load all medications
const meds = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
+ const medicationById = new Map(meds.map((med) => [med.id, med]));
// Build medication ID to export ID mapping
const medIdToExportId = new Map();
@@ -435,6 +567,7 @@ export async function exportRoutes(app: FastifyInstance) {
// 2. Load all dose tracking entries
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
+ const journalPayloadsByDoseTrackingId = await listIntakeJournalExportPayloadsForUser(userId);
const exportDoseHistory = doses
.map((dose) => {
@@ -477,6 +610,7 @@ export async function exportRoutes(app: FastifyInstance) {
takenSource: dose.takenSource === "automatic" ? "automatic" : "manual",
dismissed: dose.dismissed ?? false,
takenByPerson: parsed.person,
+ ...journalPayloadsByDoseTrackingId.get(dose.id),
};
})
.filter((d): d is NonNullable => d !== null);
@@ -509,7 +643,6 @@ export async function exportRoutes(app: FastifyInstance) {
expiryWarningDays: settings.expiryWarningDays,
language: settings.language,
stockCalculationMode: settings.stockCalculationMode,
- shareStockStatus: settings.shareStockStatus,
shareMedicationOverview: settings.shareMedicationOverview ?? false,
}
: undefined;
@@ -536,6 +669,7 @@ export async function exportRoutes(app: FastifyInstance) {
return {
takenBy: share.takenBy,
scheduleDays: share.scheduleDays,
+ allowJournalNotes: share.allowJournalNotes ?? false,
expiresAt: expiresAtIso,
regenerateToken: true, // Always regenerate tokens on import for security
};
@@ -548,6 +682,17 @@ export async function exportRoutes(app: FastifyInstance) {
.map((refill) => {
const exportId = medIdToExportId.get(refill.medicationId);
if (!exportId) return null; // Orphaned refill, skip
+ const medication = medicationById.get(refill.medicationId);
+ const packageType = normalizePackageType(medication?.packageType);
+ const pillsPerPack = Math.max(1, (medication?.blistersPerPack ?? 1) * (medication?.pillsPerBlister ?? 1));
+ const quantityAdded =
+ packageType === "bottle" ||
+ packageType === "inhaler" ||
+ packageType === "injection" ||
+ packageType === "tube" ||
+ packageType === "liquid_container"
+ ? (refill.loosePillsAdded ?? 0)
+ : (refill.packsAdded ?? 0) * pillsPerPack + (refill.loosePillsAdded ?? 0);
// Safely convert refillDate to ISO string
let refillDateIso: string;
@@ -568,6 +713,7 @@ export async function exportRoutes(app: FastifyInstance) {
medicationRef: exportId,
packsAdded: refill.packsAdded ?? 0,
loosePillsAdded: refill.loosePillsAdded ?? 0,
+ quantityAdded,
usedPrescription: refill.usedPrescription ?? false,
refillDate: refillDateIso,
};
@@ -599,6 +745,58 @@ export async function exportRoutes(app: FastifyInstance) {
}
);
+ // ---------------------------------------------------------------------------
+ // POST /import/preview - Validate and summarize import data without writing
+ // ---------------------------------------------------------------------------
+ app.post(
+ "/import/preview",
+ {
+ config: {
+ rawBody: true,
+ },
+ bodyLimit: 50 * 1024 * 1024,
+ schema: {
+ body: importBodyOpenApiSchema,
+ response: {
+ 200: importPreviewResponseSchema,
+ 400: { anyOf: [genericErrorSchema, validationErrorSchema] },
+ 401: genericErrorSchema,
+ },
+ },
+ },
+ async (request, reply) => {
+ const userId = await getUserId(request, reply);
+
+ const parsed = importDataSchema.safeParse(request.body);
+ if (!parsed.success) {
+ return reply.status(400).send({
+ error: "Invalid import data format",
+ details: parsed.error.format(),
+ });
+ }
+
+ const [existingMeds, existingDoseHistory, existingRefillHistory, existingShareLinks, existingSettings] =
+ await Promise.all([
+ db.select({ id: medications.id }).from(medications).where(eq(medications.userId, userId)),
+ db.select({ id: doseTracking.id }).from(doseTracking).where(eq(doseTracking.userId, userId)),
+ db.select({ id: refillHistory.id }).from(refillHistory).where(eq(refillHistory.userId, userId)),
+ db.select({ id: shareTokens.id }).from(shareTokens).where(eq(shareTokens.userId, userId)),
+ db.select({ id: userSettings.id }).from(userSettings).where(eq(userSettings.userId, userId)),
+ ]);
+
+ return {
+ success: true,
+ preview: buildImportPreview(parsed.data, {
+ medications: existingMeds.length,
+ doseHistory: existingDoseHistory.length,
+ refillHistory: existingRefillHistory.length,
+ shareLinks: existingShareLinks.length,
+ hasSettings: existingSettings.length > 0,
+ }),
+ };
+ }
+ );
+
// ---------------------------------------------------------------------------
// POST /import - Import user data (replaces all existing data!)
// ---------------------------------------------------------------------------
@@ -631,6 +829,7 @@ export async function exportRoutes(app: FastifyInstance) {
},
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
+ 500: genericErrorSchema,
},
},
},
@@ -648,192 +847,208 @@ export async function exportRoutes(app: FastifyInstance) {
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
+ // Existing image files are removed only after the DB import commits.
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 */
+ const oldImagePaths = existingMeds
+ .map((med) => (med.imageUrl ? resolve(IMAGES_DIR, med.imageUrl) : null))
+ .filter((path): path is string => path !== null);
+ const newImagePaths: string[] = [];
+
+ try {
+ await db.transaction(async (tx) => {
+ // Delete in order: journal entries, refill history, doses, share tokens, medications, settings.
+ await tx.delete(intakeJournal).where(eq(intakeJournal.userId, userId));
+ await tx.delete(refillHistory).where(eq(refillHistory.userId, userId));
+ await tx.delete(doseTracking).where(eq(doseTracking.userId, userId));
+ await tx.delete(shareTokens).where(eq(shareTokens.userId, userId));
+ await tx.delete(medications).where(eq(medications.userId, userId));
+ await tx.delete(userSettings).where(eq(userSettings.userId, userId));
+
+ const exportIdToNewId = new Map();
+
+ for (const med of importData.medications) {
+ const normalizedSchedules = med.schedules.map((schedule) =>
+ normalizeIntake({
+ usage: schedule.usage,
+ every: schedule.every,
+ start: schedule.start,
+ scheduleMode: schedule.scheduleMode,
+ weekdays: schedule.weekdays,
+ intakeUnit: schedule.intakeUnit ?? null,
+ takenBy: schedule.takenBy || null,
+ intakeRemindersEnabled: schedule.remind ?? false,
+ })
+ );
+ const usageJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.usage));
+ const everyJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.every));
+ const startJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.start));
+ const takenByJson = JSON.stringify(med.takenBy);
+ const intakesJson = JSON.stringify(normalizedSchedules);
+ const intakeRemindersEnabled =
+ normalizedSchedules.some((schedule) => schedule.intakeRemindersEnabled) || med.intakeRemindersEnabled;
+
+ const [inserted] = await tx
+ .insert(medications)
+ .values({
+ userId,
+ name: med.name,
+ genericName: med.genericName || null,
+ takenByJson,
+ medicationForm: med.medicationForm ?? "tablet",
+ pillForm: med.pillForm || null,
+ lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
+ packageType: normalizePackageType(med.inventory.packageType),
+ packageAmountValue: med.inventory.packageAmountValue ?? 0,
+ packageAmountUnit: med.inventory.packageAmountUnit ?? "ml",
+ packCount: med.inventory.packCount,
+ blistersPerPack: med.inventory.blistersPerPack,
+ pillsPerBlister: med.inventory.pillsPerBlister,
+ looseTablets: med.inventory.looseTablets,
+ totalPills: med.inventory.totalPills ?? null,
+ stockAdjustment: med.inventory.stockAdjustment ?? 0,
+ lastStockCorrectionAt: med.lastStockCorrectionAt ? new Date(med.lastStockCorrectionAt) : null,
+ pillWeightMg: med.pillWeightMg || null,
+ doseUnit: med.doseUnit ?? "mg",
+ medicationStartDate: med.medicationStartDate || "",
+ medicationEndDate: med.medicationEndDate || null,
+ autoMarkObsoleteAfterEndDate: med.autoMarkObsoleteAfterEndDate ?? true,
+ intakesJson,
+ usageJson,
+ everyJson,
+ startJson,
+ expiryDate: med.expiryDate || null,
+ notes: med.notes || null,
+ intakeRemindersEnabled,
+ isObsolete: med.isObsolete ?? false,
+ obsoleteAt: med.obsoleteAt ? new Date(med.obsoleteAt) : null,
+ prescriptionEnabled: med.prescriptionEnabled ?? false,
+ prescriptionAuthorizedRefills: med.prescriptionEnabled
+ ? (med.prescriptionAuthorizedRefills ?? null)
+ : null,
+ prescriptionRemainingRefills: med.prescriptionEnabled
+ ? (med.prescriptionRemainingRefills ?? null)
+ : null,
+ prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
+ prescriptionExpiryDate: med.prescriptionExpiryDate || null,
+ dismissedUntil: med.dismissedUntil || null,
+ imageUrl: null,
+ })
+ .returning();
+
+ exportIdToNewId.set(med._exportId, inserted.id);
+
+ if (med.image) {
+ const imageUrl = base64ToImage(med.image, inserted.id);
+ if (imageUrl) {
+ newImagePaths.push(resolve(IMAGES_DIR, imageUrl));
+ await tx.update(medications).set({ imageUrl }).where(eq(medications.id, inserted.id));
+ }
}
}
- }
- }
- // Delete in order: refill history, doses, share tokens, medications, settings
- await db.delete(refillHistory).where(eq(refillHistory.userId, userId));
- 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));
+ for (const dose of importData.doseHistory) {
+ const newMedId = exportIdToNewId.get(dose.medicationRef);
+ if (!newMedId) continue;
- // 3. Import medications and build ID mapping
- const exportIdToNewId = new Map();
+ const scheduledFor = new Date(dose.scheduledTime);
+ const timestampMs = scheduledFor.getTime();
+ const doseId = buildDoseId(newMedId, dose.scheduleIndex, timestampMs, dose.takenByPerson);
- for (const med of importData.medications) {
- const normalizedSchedules = med.schedules.map((schedule) =>
- normalizeIntake({
- usage: schedule.usage,
- every: schedule.every,
- start: schedule.start,
- scheduleMode: schedule.scheduleMode,
- weekdays: schedule.weekdays,
- intakeUnit: schedule.intakeUnit ?? null,
- takenBy: schedule.takenBy || null,
- intakeRemindersEnabled: schedule.remind ?? false,
- })
- );
- const usageJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.usage));
- const everyJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.every));
- const startJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.start));
- const takenByJson = JSON.stringify(med.takenBy);
+ const [insertedDose] = await tx
+ .insert(doseTracking)
+ .values({
+ userId,
+ doseId,
+ takenAt: new Date(dose.takenAt),
+ markedBy: dose.markedBy || null,
+ takenSource: dose.takenSource ?? "manual",
+ dismissed: dose.dismissed ?? false,
+ })
+ .returning({ id: doseTracking.id });
- const intakesJson = JSON.stringify(normalizedSchedules);
+ await restoreIntakeJournalForImportedDose({
+ userId,
+ doseTrackingId: insertedDose.id,
+ medicationId: newMedId,
+ scheduledFor,
+ journalNote: dose.journalNote,
+ journalCreatedAt: dose.journalCreatedAt,
+ journalUpdatedAt: dose.journalUpdatedAt,
+ database: tx,
+ });
+ }
- // Check if any schedule has remind enabled
- const intakeRemindersEnabled =
- normalizedSchedules.some((schedule) => schedule.intakeRemindersEnabled) || med.intakeRemindersEnabled;
+ if (importData.settings) {
+ await tx.insert(userSettings).values({
+ userId,
+ emailEnabled: importData.settings.emailEnabled ?? false,
+ notificationEmail: importData.settings.notificationEmail || null,
+ emailStockReminders: importData.settings.emailStockReminders ?? true,
+ emailIntakeReminders: importData.settings.emailIntakeReminders ?? true,
+ emailPrescriptionReminders: importData.settings.emailPrescriptionReminders ?? true,
+ shoutrrrEnabled: importData.settings.shoutrrrEnabled ?? false,
+ shoutrrrUrl: importData.settings.shoutrrrUrl || null,
+ shoutrrrStockReminders: importData.settings.shoutrrrStockReminders ?? true,
+ shoutrrrIntakeReminders: importData.settings.shoutrrrIntakeReminders ?? true,
+ shoutrrrPrescriptionReminders: importData.settings.shoutrrrPrescriptionReminders ?? 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,
+ expiryWarningDays: importData.settings.expiryWarningDays ?? 90,
+ language: importData.settings.language ?? "en",
+ stockCalculationMode: importData.settings.stockCalculationMode ?? "automatic",
+ shareMedicationOverview: importData.settings.shareMedicationOverview ?? false,
+ });
+ }
- const [inserted] = await db
- .insert(medications)
- .values({
- userId,
- name: med.name,
- genericName: med.genericName || null,
- takenByJson,
- medicationForm: med.medicationForm ?? "tablet",
- pillForm: med.pillForm || null,
- lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
- packageType: normalizePackageType(med.inventory.packageType),
- packageAmountValue: med.inventory.packageAmountValue ?? 0,
- packageAmountUnit: med.inventory.packageAmountUnit ?? "ml",
- packCount: med.inventory.packCount,
- blistersPerPack: med.inventory.blistersPerPack,
- pillsPerBlister: med.inventory.pillsPerBlister,
- looseTablets: med.inventory.looseTablets,
- totalPills: med.inventory.totalPills ?? null,
- stockAdjustment: med.inventory.stockAdjustment ?? 0,
- lastStockCorrectionAt: med.lastStockCorrectionAt ? new Date(med.lastStockCorrectionAt) : null,
- pillWeightMg: med.pillWeightMg || null,
- doseUnit: med.doseUnit ?? "mg",
- medicationStartDate: med.medicationStartDate || "",
- medicationEndDate: med.medicationEndDate || null,
- autoMarkObsoleteAfterEndDate: med.autoMarkObsoleteAfterEndDate ?? true,
- intakesJson,
- usageJson,
- everyJson,
- startJson,
- expiryDate: med.expiryDate || null,
- notes: med.notes || null,
- intakeRemindersEnabled,
- isObsolete: med.isObsolete ?? false,
- obsoleteAt: med.obsoleteAt ? new Date(med.obsoleteAt) : null,
- prescriptionEnabled: med.prescriptionEnabled ?? false,
- prescriptionAuthorizedRefills: med.prescriptionEnabled ? (med.prescriptionAuthorizedRefills ?? null) : null,
- prescriptionRemainingRefills: med.prescriptionEnabled ? (med.prescriptionRemainingRefills ?? null) : null,
- prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
- prescriptionExpiryDate: med.prescriptionExpiryDate || null,
- dismissedUntil: med.dismissedUntil || null,
- imageUrl: null, // Will be set after image is saved
- })
- .returning();
+ for (const share of importData.shareLinks) {
+ await tx.insert(shareTokens).values({
+ userId,
+ token: randomBytes(8).toString("hex"),
+ takenBy: share.takenBy,
+ scheduleDays: share.scheduleDays,
+ allowJournalNotes: share.allowJournalNotes ?? false,
+ expiresAt: share.expiresAt ? new Date(share.expiresAt) : null,
+ });
+ }
- // Save mapping
- exportIdToNewId.set(med._exportId, inserted.id);
+ for (const refill of importData.refillHistory) {
+ const newMedId = exportIdToNewId.get(refill.medicationRef);
+ if (!newMedId) continue;
- // 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));
+ await tx.insert(refillHistory).values({
+ medicationId: newMedId,
+ userId,
+ packsAdded: refill.packsAdded ?? 0,
+ loosePillsAdded: refill.loosePillsAdded ?? refill.quantityAdded ?? 0,
+ usedPrescription: refill.usedPrescription ?? false,
+ refillDate: new Date(refill.refillDate),
+ });
+ }
+ });
+ } catch (error) {
+ for (const imagePath of newImagePaths) {
+ const removalError = removeFileIfPresent(imagePath);
+ if (removalError) {
+ request.log.warn(`[Import] Failed to remove rolled-back image path=${imagePath}: ${removalError}`);
}
}
+
+ request.log.error({ err: error }, "[Import] Failed to import data");
+ return reply.status(500).send({ error: "Import failed" });
}
- // 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();
- // Rebuild dose ID with optional person suffix
- const doseId = buildDoseId(newMedId, dose.scheduleIndex, timestampMs, dose.takenByPerson);
-
- await db.insert(doseTracking).values({
- userId,
- doseId,
- takenAt: new Date(dose.takenAt),
- markedBy: dose.markedBy || null,
- takenSource: dose.takenSource ?? "manual",
- dismissed: dose.dismissed ?? false,
- });
- }
-
- // 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,
- emailPrescriptionReminders: importData.settings.emailPrescriptionReminders ?? true,
- shoutrrrEnabled: importData.settings.shoutrrrEnabled ?? false,
- shoutrrrUrl: importData.settings.shoutrrrUrl || null,
- shoutrrrStockReminders: importData.settings.shoutrrrStockReminders ?? true,
- shoutrrrIntakeReminders: importData.settings.shoutrrrIntakeReminders ?? true,
- shoutrrrPrescriptionReminders: importData.settings.shoutrrrPrescriptionReminders ?? 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,
- expiryWarningDays: importData.settings.expiryWarningDays ?? 90,
- language: importData.settings.language ?? "en",
- stockCalculationMode: importData.settings.stockCalculationMode ?? "automatic",
- shareStockStatus: importData.settings.shareStockStatus ?? true,
- shareMedicationOverview: importData.settings.shareMedicationOverview ?? false,
- });
- }
-
- // 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,
- });
- }
-
- // 7. Import refill history with remapped medication IDs
- for (const refill of importData.refillHistory) {
- const newMedId = exportIdToNewId.get(refill.medicationRef);
- if (!newMedId) continue; // Skip orphaned refill records
-
- await db.insert(refillHistory).values({
- medicationId: newMedId,
- userId,
- packsAdded: refill.packsAdded ?? 0,
- loosePillsAdded: refill.loosePillsAdded ?? 0,
- usedPrescription: refill.usedPrescription ?? false,
- refillDate: new Date(refill.refillDate),
- });
+ for (const imagePath of oldImagePaths) {
+ const removalError = removeFileIfPresent(imagePath);
+ if (removalError) {
+ request.log.warn(`[Import] Failed to remove replaced image path=${imagePath}: ${removalError}`);
+ }
}
return {
diff --git a/backend/src/routes/intake-journal.ts b/backend/src/routes/intake-journal.ts
new file mode 100644
index 0000000..8b11ba0
--- /dev/null
+++ b/backend/src/routes/intake-journal.ts
@@ -0,0 +1,373 @@
+import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
+import { z } from "zod";
+import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
+import { env } from "../plugins/env.js";
+import {
+ deleteIntakeJournalForDoseEvent,
+ getIntakeJournalForDoseEvent,
+ isTrackedDoseIdFormat,
+ listIntakeJournalEntriesForUser,
+ resolveTrackedDoseEventForUser,
+ upsertIntakeJournalForDoseEvent,
+} from "../services/intake-journal-service.js";
+import type { AuthUser } from "../types/fastify.js";
+import { toLocalDateTimeOffsetString } from "../utils/local-date-time.js";
+import {
+ applyOpenApiRouteStandards,
+ genericErrorSchema,
+ validationErrorSchema,
+} from "../utils/openapi-route-standards.js";
+
+const intakeJournalEndpointSecurity: ReadonlyArray> = [
+ { bearerAuth: [] },
+ { cookieAuth: [] },
+];
+
+const doseIdParamsSchema = {
+ type: "object",
+ required: ["doseId"],
+ properties: {
+ doseId: { type: "string", minLength: 1 },
+ },
+} as const;
+
+const intakeJournalEntrySchema = {
+ type: "object",
+ required: [
+ "doseTrackingId",
+ "doseId",
+ "medicationId",
+ "medicationName",
+ "scheduledFor",
+ "dismissed",
+ "takenSource",
+ "note",
+ "updatedAt",
+ ],
+ properties: {
+ doseTrackingId: { type: "integer" },
+ doseId: { type: "string" },
+ medicationId: { type: "integer" },
+ medicationName: { type: "string" },
+ scheduledFor: { type: "string", format: "date-time" },
+ takenAt: { type: ["string", "null"], format: "date-time" },
+ dismissed: { type: "boolean" },
+ takenSource: { type: "string", enum: ["manual", "automatic"] },
+ markedBy: { type: ["string", "null"] },
+ note: { type: ["string", "null"] },
+ updatedAt: { type: ["string", "null"], format: "date-time" },
+ createdAt: { type: ["string", "null"], format: "date-time" },
+ },
+ additionalProperties: false,
+} as const;
+
+const intakeJournalEventResponseSchema = {
+ type: "object",
+ required: ["entry"],
+ properties: {
+ entry: intakeJournalEntrySchema,
+ },
+ additionalProperties: false,
+} as const;
+
+const intakeJournalHistoryResponseSchema = {
+ type: "object",
+ required: ["entries"],
+ properties: {
+ entries: {
+ type: "array",
+ items: intakeJournalEntrySchema,
+ },
+ },
+ additionalProperties: false,
+} as const;
+
+const intakeJournalHistoryQuerySchema = z.object({
+ medicationId: z.coerce.number().int().positive().optional(),
+ from: z.string().trim().min(1).optional(),
+ to: z.string().trim().min(1).optional(),
+ limit: z.coerce.number().int().min(1).max(200).optional().default(100),
+});
+
+const intakeJournalUpsertSchema = z.object({
+ note: z.string().max(4000),
+});
+
+function getValidationErrorMessage(error: z.ZodError): string {
+ const issue = error.issues[0];
+ if (!issue) {
+ return "Invalid request payload";
+ }
+
+ return issue.message;
+}
+
+function parseOptionalDate(value: string | undefined): Date | null {
+ if (!value) {
+ return null;
+ }
+
+ const parsed = new Date(value);
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
+}
+
+function serializeTakenAt(value: Date | null, dismissed: boolean): string | null {
+ if (!(value instanceof Date) || Number.isNaN(value.getTime())) {
+ return null;
+ }
+
+ if (dismissed && value.getTime() <= 0) {
+ return null;
+ }
+
+ return value.toISOString();
+}
+
+function buildJournalEntryDto(input: {
+ event: Awaited> extends infer T
+ ? T extends null
+ ? never
+ : T
+ : never;
+ journalEntry: Awaited>;
+}) {
+ const { event, journalEntry } = input;
+
+ return {
+ doseTrackingId: event.doseTrackingId,
+ doseId: event.doseId,
+ medicationId: event.medicationId,
+ medicationName: event.medicationName,
+ scheduledFor: toLocalDateTimeOffsetString(journalEntry?.scheduledFor ?? event.scheduledFor),
+ takenAt: serializeTakenAt(event.takenAt, event.dismissed),
+ dismissed: event.dismissed,
+ takenSource: event.takenSource,
+ markedBy: event.markedBy,
+ note: journalEntry?.note ?? null,
+ updatedAt: journalEntry?.updatedAt?.toISOString() ?? null,
+ createdAt: journalEntry?.createdAt?.toISOString() ?? null,
+ };
+}
+
+async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise {
+ if (!env.AUTH_ENABLED) {
+ return getAnonymousUserId();
+ }
+
+ const authUser = request.user as AuthUser | null;
+ if (!authUser) {
+ reply.status(401).send({ error: "Not authenticated" });
+ throw new Error("AUTH_REQUIRED");
+ }
+
+ return authUser.id;
+}
+
+export async function intakeJournalRoutes(app: FastifyInstance) {
+ app.addHook("preHandler", requireAuth);
+ applyOpenApiRouteStandards(app, { tag: "intake-journal", protectedByDefault: true });
+
+ app.get<{ Querystring: z.infer }>(
+ "/intake-journal",
+ {
+ schema: {
+ tags: ["intake-journal"],
+ summary: "List intake journal history for the current owner",
+ security: intakeJournalEndpointSecurity,
+ querystring: {
+ type: "object",
+ properties: {
+ medicationId: { type: "integer", minimum: 1 },
+ from: { type: "string", format: "date-time" },
+ to: { type: "string", format: "date-time" },
+ limit: { type: "integer", minimum: 1, maximum: 200 },
+ },
+ },
+ response: {
+ 200: intakeJournalHistoryResponseSchema,
+ 400: { anyOf: [genericErrorSchema, validationErrorSchema] },
+ 401: genericErrorSchema,
+ },
+ },
+ },
+ async (request, reply) => {
+ const userId = await getUserId(request, reply);
+ const parsed = intakeJournalHistoryQuerySchema.safeParse(request.query);
+
+ if (!parsed.success) {
+ return reply.status(400).send({ error: getValidationErrorMessage(parsed.error) });
+ }
+
+ const from = parseOptionalDate(parsed.data.from);
+ if (parsed.data.from && !from) {
+ return reply.status(400).send({ error: "Invalid 'from' date-time filter", code: "INVALID_FROM" });
+ }
+
+ const to = parseOptionalDate(parsed.data.to);
+ if (parsed.data.to && !to) {
+ return reply.status(400).send({ error: "Invalid 'to' date-time filter", code: "INVALID_TO" });
+ }
+
+ if (from && to && from.getTime() > to.getTime()) {
+ return reply.status(400).send({ error: "'from' must be before or equal to 'to'", code: "INVALID_RANGE" });
+ }
+
+ const entries = await listIntakeJournalEntriesForUser({
+ userId,
+ medicationId: parsed.data.medicationId,
+ from: from ?? undefined,
+ to: to ?? undefined,
+ limit: parsed.data.limit,
+ });
+
+ return {
+ entries: entries.map((entry) => ({
+ doseTrackingId: entry.doseTrackingId,
+ doseId: entry.doseId,
+ medicationId: entry.medicationId,
+ medicationName: entry.medicationName,
+ scheduledFor: toLocalDateTimeOffsetString(entry.scheduledFor),
+ takenAt: serializeTakenAt(entry.takenAt, entry.dismissed),
+ dismissed: entry.dismissed,
+ takenSource: entry.takenSource,
+ markedBy: entry.markedBy,
+ note: entry.note,
+ updatedAt: entry.updatedAt.toISOString(),
+ createdAt: entry.createdAt.toISOString(),
+ })),
+ };
+ }
+ );
+
+ app.get<{ Params: { doseId: string } }>(
+ "/intake-journal/event/:doseId",
+ {
+ schema: {
+ tags: ["intake-journal"],
+ summary: "Get intake journal context for a tracked dose event",
+ security: intakeJournalEndpointSecurity,
+ params: doseIdParamsSchema,
+ response: {
+ 200: intakeJournalEventResponseSchema,
+ 400: { anyOf: [genericErrorSchema, validationErrorSchema] },
+ 401: genericErrorSchema,
+ 404: genericErrorSchema,
+ },
+ },
+ },
+ async (request, reply) => {
+ const userId = await getUserId(request, reply);
+ const { doseId } = request.params;
+
+ if (!isTrackedDoseIdFormat(doseId)) {
+ return reply.status(400).send({ error: "Invalid doseId format", code: "INVALID_DOSE" });
+ }
+
+ const event = await resolveTrackedDoseEventForUser({ userId, doseId });
+ if (!event) {
+ return reply
+ .status(404)
+ .send({ error: "Tracked dose event not found for the current owner", code: "DOSE_NOT_FOUND" });
+ }
+
+ const journalEntry = await getIntakeJournalForDoseEvent({ userId, doseId });
+ return { entry: buildJournalEntryDto({ event, journalEntry }) };
+ }
+ );
+
+ app.put<{ Body: z.infer; Params: { doseId: string } }>(
+ "/intake-journal/event/:doseId",
+ {
+ schema: {
+ tags: ["intake-journal"],
+ summary: "Create or update an intake journal note for a tracked dose event",
+ security: intakeJournalEndpointSecurity,
+ params: doseIdParamsSchema,
+ body: {
+ type: "object",
+ required: ["note"],
+ properties: {
+ note: { type: "string", maxLength: 4000 },
+ },
+ additionalProperties: false,
+ },
+ response: {
+ 200: intakeJournalEventResponseSchema,
+ 400: { anyOf: [genericErrorSchema, validationErrorSchema] },
+ 401: genericErrorSchema,
+ 404: genericErrorSchema,
+ },
+ },
+ },
+ async (request, reply) => {
+ const userId = await getUserId(request, reply);
+ const { doseId } = request.params;
+
+ if (!isTrackedDoseIdFormat(doseId)) {
+ return reply.status(400).send({ error: "Invalid doseId format", code: "INVALID_DOSE" });
+ }
+
+ const parsed = intakeJournalUpsertSchema.safeParse(request.body);
+ if (!parsed.success) {
+ return reply.status(400).send({ error: getValidationErrorMessage(parsed.error) });
+ }
+
+ const event = await resolveTrackedDoseEventForUser({ userId, doseId });
+ if (!event) {
+ return reply
+ .status(404)
+ .send({ error: "Tracked dose event not found for the current owner", code: "DOSE_NOT_FOUND" });
+ }
+
+ const journalEntry = await upsertIntakeJournalForDoseEvent({
+ userId,
+ doseId,
+ note: parsed.data.note,
+ });
+
+ return { entry: buildJournalEntryDto({ event, journalEntry }) };
+ }
+ );
+
+ app.delete<{ Params: { doseId: string } }>(
+ "/intake-journal/event/:doseId",
+ {
+ schema: {
+ tags: ["intake-journal"],
+ summary: "Delete an intake journal note for a tracked dose event",
+ security: intakeJournalEndpointSecurity,
+ params: doseIdParamsSchema,
+ response: {
+ 200: {
+ type: "object",
+ required: ["success"],
+ properties: {
+ success: { type: "boolean" },
+ },
+ additionalProperties: false,
+ },
+ 400: { anyOf: [genericErrorSchema, validationErrorSchema] },
+ 401: genericErrorSchema,
+ 404: genericErrorSchema,
+ },
+ },
+ },
+ async (request, reply) => {
+ const userId = await getUserId(request, reply);
+ const { doseId } = request.params;
+
+ if (!isTrackedDoseIdFormat(doseId)) {
+ return reply.status(400).send({ error: "Invalid doseId format", code: "INVALID_DOSE" });
+ }
+
+ const deleted = await deleteIntakeJournalForDoseEvent({ userId, doseId });
+ if (!deleted) {
+ return reply
+ .status(404)
+ .send({ error: "Tracked dose event not found for the current owner", code: "DOSE_NOT_FOUND" });
+ }
+
+ return { success: true };
+ }
+ );
+}
diff --git a/backend/src/routes/medication-enrichment.ts b/backend/src/routes/medication-enrichment.ts
index b509114..3b550b9 100644
--- a/backend/src/routes/medication-enrichment.ts
+++ b/backend/src/routes/medication-enrichment.ts
@@ -70,7 +70,10 @@ const strengthOptionSchema = {
label: { type: "string" },
pillWeightMg: { type: "number", nullable: true },
doseUnit: {
- anyOf: [{ type: "string", enum: ["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"] }, { type: "null" }],
+ anyOf: [
+ { type: "string", enum: ["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs", "injections"] },
+ { type: "null" },
+ ],
},
},
} as const;
@@ -80,7 +83,7 @@ const packageOptionSchema = {
properties: {
label: { type: "string" },
description: { type: "string" },
- packageType: { type: "string", enum: ["blister", "bottle", "tube", "liquid_container"] },
+ packageType: { type: "string", enum: ["blister", "bottle", "tube", "liquid_container", "inhaler", "injection"] },
packCount: { type: "integer", minimum: 1 },
blistersPerPack: { type: "integer", minimum: 1, nullable: true },
pillsPerBlister: { type: "integer", minimum: 1, nullable: true },
diff --git a/backend/src/routes/medications.ts b/backend/src/routes/medications.ts
index 2331924..c63b2c6 100644
--- a/backend/src/routes/medications.ts
+++ b/backend/src/routes/medications.ts
@@ -24,6 +24,7 @@ import {
} from "../utils/openapi-route-standards.js";
import {
isAmountBasedPackageType,
+ isDiscreteCountPackageType,
isLiquidContainerPackageType,
isTubePackageType,
normalizePackageType,
@@ -67,7 +68,7 @@ const packageTypeSchema = z.enum(PACKAGE_TYPES).default("blister");
const medicationFormSchema = z.enum(["capsule", "tablet", "liquid", "topical"]).default("tablet");
const pillFormSchema = z.enum(["capsule", "tablet"]);
const lifecycleCategorySchema = z.enum(["refill_when_empty", "treatment_period"]).default("refill_when_empty");
-const doseUnitSchema = z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg");
+const doseUnitSchema = z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs", "injections"]).default("mg");
const medicationStartDateSchema = z
.union([z.string().regex(/^\d{4}-\d{2}-\d{2}$/), z.literal(""), z.null()])
.optional();
@@ -264,7 +265,7 @@ const medicationBodyOpenApiSchema = {
totalPills: { type: ["integer", "null"], minimum: 1 },
looseTablets: { type: "integer", minimum: 0 },
pillWeightMg: { type: ["number", "null"], minimum: 0 },
- doseUnit: { type: "string", enum: ["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"] },
+ doseUnit: { type: "string", enum: ["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs", "injections"] },
medicationStartDate: {
anyOf: [{ type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$" }, { type: "null" }, { const: "" }],
},
@@ -1201,17 +1202,20 @@ export async function medicationRoutes(app: FastifyInstance) {
const packageType = normalizePackageType(existing.packageType);
const allowsAmountBaseUpdate = isTubePackageType(packageType) || isLiquidContainerPackageType(packageType);
- const allowsBottleCapacityUpdate = packageType === "bottle";
+ const allowsDiscreteCapacityUpdate = isDiscreteCountPackageType(packageType);
if (allowsAmountBaseUpdate) {
- if (totalPills !== undefined) updateFields.totalPills = totalPills;
- if (looseTablets !== undefined) updateFields.looseTablets = looseTablets;
+ const normalizedAmountBase = looseTablets ?? totalPills;
+ if (normalizedAmountBase !== undefined) {
+ updateFields.totalPills = normalizedAmountBase;
+ updateFields.looseTablets = normalizedAmountBase;
+ }
if (packageAmountValue !== undefined) updateFields.packageAmountValue = packageAmountValue;
}
- if (allowsBottleCapacityUpdate && totalPills !== undefined) {
+ if (allowsDiscreteCapacityUpdate && totalPills !== undefined) {
updateFields.totalPills = totalPills;
}
if (packCount !== undefined) updateFields.packCount = packCount;
- if (looseTablets !== undefined) {
+ if (!allowsAmountBaseUpdate && looseTablets !== undefined) {
updateFields.looseTablets = looseTablets;
}
@@ -1654,7 +1658,7 @@ export async function medicationRoutes(app: FastifyInstance) {
async (req, reply) => {
const parsed = dismissUntilSchema.safeParse(req.body);
if (!parsed.success) {
- return reply.status(400).send({ error: parsed.error.errors[0]?.message ?? "Invalid input" });
+ return reply.status(400).send({ error: parsed.error.issues[0]?.message ?? "Invalid input" });
}
const userId = await getUserId(req, reply);
diff --git a/backend/src/routes/notification-actions.ts b/backend/src/routes/notification-actions.ts
new file mode 100644
index 0000000..996c0cc
--- /dev/null
+++ b/backend/src/routes/notification-actions.ts
@@ -0,0 +1,670 @@
+import formbody from "@fastify/formbody";
+import { eq } from "drizzle-orm";
+import type { FastifyInstance, FastifyRequest } from "fastify";
+import { z } from "zod";
+import { db } from "../db/client.js";
+import { notificationActionGroups, notificationActionTokens, userSettings } from "../db/schema.js";
+import { getTranslations, type Language } from "../i18n/translations.js";
+import { markDoseTakenForUser, skipDosesForUser } from "../services/dose-tracking-service.js";
+import {
+ getNotificationActionTokenRecord,
+ isNotificationActionExpired,
+} from "../services/notification-actions-service.js";
+import { getNotificationActionLabels } from "../services/notifications/action-renderer.js";
+import { sendPushNotification } from "../services/notifications/delivery.js";
+import { sanitizeNotificationUrl } from "../services/settings-service.js";
+import { applyOpenApiRouteStandards, genericErrorSchema } from "../utils/openapi-route-standards.js";
+
+const querySchema = z.object({
+ action: z.enum(["taken", "skip", "dismiss"]).optional(),
+});
+
+type NotificationMutationAction = "taken" | "skip";
+
+function normalizeNotificationAction(action: string | null | undefined): NotificationMutationAction | null {
+ if (action === "taken") {
+ return "taken";
+ }
+
+ if (action === "skip" || action === "dismiss") {
+ return "skip";
+ }
+
+ return null;
+}
+
+const publicNotificationActionMethods = "GET,HEAD,POST,OPTIONS";
+const reminderFooterSeparator = "\n\n---\n";
+
+function escapeHtml(value: string): string {
+ return value
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+}
+
+function toHtmlText(value: string): string {
+ return escapeHtml(value).replaceAll("\n", "
");
+}
+
+function getLanguage(language: string | null): Language {
+ return language === "de" ? "de" : "en";
+}
+
+function wantsHtml(request: FastifyRequest): boolean {
+ return request.headers.accept?.includes("text/html") ?? false;
+}
+
+function applyPublicNotificationCorsHeaders(
+ request: FastifyRequest,
+ reply: { header: (name: string, value: string) => unknown }
+) {
+ const requestOrigin = typeof request.headers.origin === "string" ? request.headers.origin : "*";
+ reply.header("access-control-allow-origin", requestOrigin);
+ reply.header("access-control-allow-methods", publicNotificationActionMethods);
+ reply.header("access-control-allow-headers", "content-type");
+ if (requestOrigin !== "*") {
+ reply.header("vary", "Origin");
+ }
+}
+
+function getAlreadyProcessedText(language: Language, resolvedAction: NotificationMutationAction) {
+ if (resolvedAction === "taken") {
+ return {
+ bodyTitle: language === "de" ? "Bereits verarbeitet" : "Already processed",
+ bodyText:
+ language === "de"
+ ? "Diese Einnahme ist bereits als genommen markiert. Wenn Sie das ändern möchten, öffnen Sie MedAssist und machen Sie die Einnahme dort rückgängig."
+ : "This dose is already marked as taken. If you need to change it, open MedAssist and undo it there.",
+ jsonMessage:
+ language === "de"
+ ? "Diese Einnahme ist bereits als genommen markiert. Änderungen sind nur in MedAssist möglich."
+ : "This dose is already marked as taken. Changes can only be made in MedAssist.",
+ };
+ }
+
+ return {
+ bodyTitle: language === "de" ? "Bereits verarbeitet" : "Already processed",
+ bodyText:
+ language === "de"
+ ? "Diese Einnahme ist bereits als übersprungen markiert. Wenn Sie sie stattdessen als genommen markieren möchten, öffnen Sie MedAssist und machen Sie das dort."
+ : "This intake is already marked as skipped. If you want to mark it as taken instead, open MedAssist and do that there.",
+ jsonMessage:
+ language === "de"
+ ? "Diese Einnahme ist bereits als übersprungen markiert. Änderungen sind nur in MedAssist möglich."
+ : "This intake is already marked as skipped. Changes can only be made in MedAssist.",
+ };
+}
+
+function getActionRecordedText(language: Language, action: NotificationMutationAction) {
+ if (action === "taken") {
+ return {
+ bodyTitle: language === "de" ? "Aktion gespeichert" : "Action recorded",
+ bodyText: language === "de" ? "Die Einnahme wurde als genommen markiert." : "The dose was marked as taken.",
+ };
+ }
+
+ return {
+ bodyTitle: language === "de" ? "Aktion gespeichert" : "Action recorded",
+ bodyText: language === "de" ? "Die Einnahme wurde als übersprungen markiert." : "The intake was marked as skipped.",
+ };
+}
+
+function buildReplacementReminderMessage(
+ language: Language,
+ action: NotificationMutationAction,
+ originalMessage: string
+): string {
+ const tr = getTranslations(language);
+ const confirmationLine = action === "taken" ? tr.push.intakeTakenConfirmation : tr.push.intakeSkippedConfirmation;
+ const separatorIndex = originalMessage.indexOf(reminderFooterSeparator);
+
+ if (separatorIndex >= 0) {
+ const beforeFooter = originalMessage.slice(0, separatorIndex).trimEnd();
+ const footer = originalMessage.slice(separatorIndex);
+ return `${beforeFooter}\n\n${confirmationLine}${footer}`;
+ }
+
+ return `${originalMessage.trimEnd()}\n\n${confirmationLine}`;
+}
+
+async function clearNtfyNotificationSequence(userId: number, sequenceId: string): Promise {
+ const [settings] = await db
+ .select({ shoutrrrEnabled: userSettings.shoutrrrEnabled, shoutrrrUrl: userSettings.shoutrrrUrl })
+ .from(userSettings)
+ .where(eq(userSettings.userId, userId));
+
+ if (!settings?.shoutrrrEnabled || !settings.shoutrrrUrl) {
+ return;
+ }
+
+ const sanitized = sanitizeNotificationUrl(settings.shoutrrrUrl);
+ if ("error" in sanitized || !sanitized.isNtfy) {
+ return;
+ }
+
+ const clearUrl = new URL(sanitized.url);
+ clearUrl.pathname = `${clearUrl.pathname.replace(/\/+$/, "")}/${encodeURIComponent(sequenceId)}/clear`;
+
+ const headers: Record = {};
+ if (sanitized.auth) {
+ headers.Authorization = `Basic ${Buffer.from(`${sanitized.auth.user}:${sanitized.auth.pass}`).toString("base64")}`;
+ }
+
+ const response = await fetch(clearUrl.toString(), {
+ method: "PUT",
+ headers,
+ redirect: "error",
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(`HTTP ${response.status}: ${errorText}`);
+ }
+}
+
+async function deleteNtfyNotificationSequence(userId: number, sequenceId: string): Promise {
+ const normalizedSequenceId = sequenceId.trim();
+ if (normalizedSequenceId.length === 0) {
+ return;
+ }
+
+ const [settings] = await db
+ .select({ shoutrrrEnabled: userSettings.shoutrrrEnabled, shoutrrrUrl: userSettings.shoutrrrUrl })
+ .from(userSettings)
+ .where(eq(userSettings.userId, userId));
+
+ if (!settings?.shoutrrrEnabled || !settings.shoutrrrUrl) {
+ return;
+ }
+
+ const sanitized = sanitizeNotificationUrl(settings.shoutrrrUrl);
+ if ("error" in sanitized || !sanitized.isNtfy) {
+ return;
+ }
+
+ const deleteUrl = new URL(sanitized.url);
+ deleteUrl.pathname = `${deleteUrl.pathname.replace(/\/+$/, "")}/${encodeURIComponent(normalizedSequenceId)}`;
+
+ const headers: Record = {};
+ if (sanitized.auth) {
+ headers.Authorization = `Basic ${Buffer.from(`${sanitized.auth.user}:${sanitized.auth.pass}`).toString("base64")}`;
+ }
+
+ const response = await fetch(deleteUrl.toString(), {
+ method: "DELETE",
+ headers,
+ redirect: "error",
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(`HTTP ${response.status}: ${errorText}`);
+ }
+}
+
+async function replaceNtfyNotificationSequence(options: {
+ userId: number;
+ sequenceId: string;
+ language: Language;
+ title: string;
+ originalMessage: string;
+ action: NotificationMutationAction;
+ viewUrl: string | null;
+}): Promise<{ replaced: boolean; providerMessageId?: string }> {
+ const normalizedSequenceId = options.sequenceId.trim();
+ if (normalizedSequenceId.length === 0) {
+ return { replaced: false };
+ }
+
+ const [settings] = await db
+ .select({ shoutrrrEnabled: userSettings.shoutrrrEnabled, shoutrrrUrl: userSettings.shoutrrrUrl })
+ .from(userSettings)
+ .where(eq(userSettings.userId, options.userId));
+
+ if (!settings?.shoutrrrEnabled || !settings.shoutrrrUrl) {
+ return { replaced: false };
+ }
+
+ const sanitized = sanitizeNotificationUrl(settings.shoutrrrUrl);
+ if ("error" in sanitized || !sanitized.isNtfy) {
+ return { replaced: false };
+ }
+
+ const labels = getNotificationActionLabels(options.language);
+ const replacementMessage = buildReplacementReminderMessage(options.language, options.action, options.originalMessage);
+ const result = await sendPushNotification(settings.shoutrrrUrl, options.title, replacementMessage, {
+ actions: options.viewUrl ? [{ kind: "view", label: labels.view, url: options.viewUrl, method: "GET" }] : undefined,
+ viewUrl: options.viewUrl ?? undefined,
+ clickUrl: options.viewUrl ?? undefined,
+ sequenceId: normalizedSequenceId,
+ tags: ["pill"],
+ });
+
+ if (!result.success) {
+ throw new Error(result.error ?? "Failed to replace ntfy notification");
+ }
+
+ return { replaced: true, providerMessageId: result.providerMessageId };
+}
+
+function renderPage(options: {
+ language: Language;
+ title: string;
+ message: string;
+ bodyTitle: string;
+ bodyText: string;
+ viewUrl: string | null;
+ actionButtons: Array<{ label: string; formAction?: string }>;
+}): string {
+ const labels = getNotificationActionLabels(options.language);
+ const forms =
+ options.actionButtons.length > 0
+ ? `${options.actionButtons
+ .map((button) => {
+ const formAction = button.formAction ? ` action="${escapeHtml(button.formAction)}"` : "";
+ return ``;
+ })
+ .join("")}
`
+ : "";
+ const viewLink = options.viewUrl
+ ? `${escapeHtml(labels.view)}
`
+ : "";
+
+ return `
+
+
+
+
+ ${escapeHtml(options.bodyTitle)}
+
+
+
+
+ ${escapeHtml(options.bodyTitle)}
+ ${escapeHtml(options.bodyText)}
+
+
${escapeHtml(options.title)}
+
${toHtmlText(options.message)}
+
+ ${forms}
+ ${viewLink}
+
+
+`;
+}
+
+function parseRequestedAction(request: FastifyRequest, tokenKind: string): NotificationMutationAction | null {
+ const normalizedTokenAction = normalizeNotificationAction(tokenKind);
+ if (normalizedTokenAction) {
+ return normalizedTokenAction;
+ }
+
+ const parsedQuery = querySchema.safeParse(request.query);
+ if (parsedQuery.success && parsedQuery.data.action) {
+ return normalizeNotificationAction(parsedQuery.data.action);
+ }
+
+ const body = request.body;
+ if (body && typeof body === "object" && "action" in body) {
+ const actionValue = (body as { action?: unknown }).action;
+ if (typeof actionValue === "string") {
+ return normalizeNotificationAction(actionValue);
+ }
+ }
+
+ return null;
+}
+
+function buildNotificationActionLogContext(
+ record: Awaited> extends infer T ? Exclude : never,
+ extra: Record = {}
+) {
+ return {
+ groupId: record.group.id,
+ userId: record.group.userId,
+ tokenKind: record.token.kind,
+ doseCount: record.doseIds.length,
+ hasViewUrl: record.viewUrl !== null,
+ ...extra,
+ };
+}
+
+function buildNotificationRequestLogContext(request: FastifyRequest, extra: Record = {}) {
+ return {
+ method: request.method,
+ hasOrigin: typeof request.headers.origin === "string",
+ expectsHtml: wantsHtml(request),
+ ...extra,
+ };
+}
+
+export async function notificationActionRoutes(app: FastifyInstance) {
+ await app.register(formbody);
+
+ applyOpenApiRouteStandards(app, { tag: "notification-actions", protectedByDefault: false });
+
+ app.options<{ Params: { token: string } }>("/notification-actions/:token", async (request, reply) => {
+ applyPublicNotificationCorsHeaders(request, reply);
+ return reply.status(204).send();
+ });
+
+ app.get<{ Params: { token: string } }>(
+ "/notification-actions/:token",
+ {
+ config: {
+ rateLimit: { max: 30, timeWindow: "1 minute" },
+ },
+ schema: {
+ tags: ["notification-actions"],
+ params: {
+ type: "object",
+ required: ["token"],
+ properties: { token: { type: "string", minLength: 1 } },
+ },
+ response: {
+ 404: genericErrorSchema,
+ 405: genericErrorSchema,
+ 410: genericErrorSchema,
+ },
+ },
+ },
+ async (request, reply) => {
+ applyPublicNotificationCorsHeaders(request, reply);
+
+ const record = await getNotificationActionTokenRecord(request.params.token);
+ if (!record) {
+ request.log.warn(
+ buildNotificationRequestLogContext(request),
+ "[NotificationActions] Unknown notification action token requested"
+ );
+ return reply.status(404).send({ error: "Notification action not found" });
+ }
+
+ if (isNotificationActionExpired(record)) {
+ request.log.warn(
+ buildNotificationActionLogContext(record),
+ "[NotificationActions] Rejected expired notification action GET request"
+ );
+ return reply.status(410).send({ error: "Notification action has expired" });
+ }
+
+ if (record.token.kind !== "respond" && record.group.resolvedAction === null) {
+ request.log.warn(
+ buildNotificationActionLogContext(record),
+ "[NotificationActions] Rejected direct GET for unresolved non-respond token"
+ );
+ return reply.status(405).send({ error: "Direct GET is only available for respond actions" });
+ }
+
+ const language = getLanguage(record.group.language ?? null);
+ const labels = getNotificationActionLabels(language);
+ const resolvedAction = normalizeNotificationAction(record.group.resolvedAction);
+ let bodyTitle: string;
+ let bodyText: string;
+ let actionButtons: Array<{ label: string; formAction?: string }> = [];
+
+ if (resolvedAction) {
+ ({ bodyTitle, bodyText } = getAlreadyProcessedText(language, resolvedAction));
+ } else {
+ if (record.token.kind === "taken") {
+ bodyTitle = language === "de" ? "Einnahme bestätigen" : "Confirm dose";
+ bodyText =
+ language === "de"
+ ? "Bestätigen Sie, dass diese Einnahme als genommen markiert werden soll."
+ : "Confirm that this dose should be marked as taken.";
+ actionButtons = [{ label: labels.taken }];
+ } else if (record.token.kind === "skip" || record.token.kind === "dismiss") {
+ bodyTitle = language === "de" ? "Einnahme überspringen" : "Skip intake";
+ bodyText =
+ language === "de"
+ ? "Bestätigen Sie, dass diese Einnahme als übersprungen markiert werden soll."
+ : "Confirm that this intake should be marked as skipped.";
+ actionButtons = [{ label: labels.skip }];
+ } else {
+ bodyTitle = language === "de" ? "Erinnerung beantworten" : "Respond to reminder";
+ bodyText =
+ language === "de"
+ ? "Wählen Sie eine Aktion für diese Medikamentenerinnerung."
+ : "Choose an action for this medication reminder.";
+ actionButtons = [
+ { label: labels.taken, formAction: "?action=taken" },
+ { label: labels.skip, formAction: "?action=skip" },
+ ];
+ }
+ }
+
+ return reply.type("text/html; charset=utf-8").send(
+ renderPage({
+ language,
+ title: record.group.title,
+ message: record.group.message,
+ bodyTitle,
+ bodyText,
+ viewUrl: record.viewUrl,
+ actionButtons: resolvedAction ? [] : actionButtons,
+ })
+ );
+ }
+ );
+
+ app.post<{ Params: { token: string } }>(
+ "/notification-actions/:token",
+ {
+ config: {
+ rateLimit: { max: 30, timeWindow: "1 minute" },
+ },
+ schema: {
+ tags: ["notification-actions"],
+ params: {
+ type: "object",
+ required: ["token"],
+ properties: { token: { type: "string", minLength: 1 } },
+ },
+ response: {
+ 400: genericErrorSchema,
+ 404: genericErrorSchema,
+ 409: genericErrorSchema,
+ 410: genericErrorSchema,
+ },
+ },
+ },
+ async (request, reply) => {
+ applyPublicNotificationCorsHeaders(request, reply);
+
+ const record = await getNotificationActionTokenRecord(request.params.token);
+ if (!record) {
+ request.log.warn(
+ buildNotificationRequestLogContext(request),
+ "[NotificationActions] Unknown notification action token requested"
+ );
+ return reply.status(404).send({ error: "Notification action not found" });
+ }
+
+ if (isNotificationActionExpired(record)) {
+ request.log.warn(
+ buildNotificationActionLogContext(record),
+ "[NotificationActions] Rejected expired notification action POST request"
+ );
+ return reply.status(410).send({ error: "Notification action has expired" });
+ }
+
+ const action = parseRequestedAction(request, record.token.kind);
+ if (!action) {
+ request.log.warn(
+ buildNotificationActionLogContext(record),
+ "[NotificationActions] Missing or invalid action for notification action POST request"
+ );
+ return reply.status(400).send({ error: "Notification action is required" });
+ }
+
+ const language = getLanguage(record.group.language ?? null);
+ const resolvedAction = normalizeNotificationAction(record.group.resolvedAction);
+ if (resolvedAction) {
+ request.log.info(
+ buildNotificationActionLogContext(record, { requestedAction: action, resolvedAction }),
+ "[NotificationActions] Ignored notification action because it was already resolved"
+ );
+ const alreadyProcessedText = getAlreadyProcessedText(language, resolvedAction);
+
+ if (wantsHtml(request)) {
+ return reply.type("text/html; charset=utf-8").send(
+ renderPage({
+ language,
+ title: record.group.title,
+ message: record.group.message,
+ bodyTitle: alreadyProcessedText.bodyTitle,
+ bodyText: alreadyProcessedText.bodyText,
+ viewUrl: record.viewUrl,
+ actionButtons: [],
+ })
+ );
+ }
+
+ return reply.send({
+ success: true,
+ action: resolvedAction,
+ alreadyProcessed: true,
+ message: alreadyProcessedText.jsonMessage,
+ });
+ }
+
+ if (action === "taken") {
+ for (const [doseIndex, doseId] of record.doseIds.entries()) {
+ const result = await markDoseTakenForUser({
+ userId: record.group.userId,
+ doseId,
+ source: "notification",
+ markedBy: null,
+ });
+
+ if (!result.success) {
+ request.log.warn(
+ buildNotificationActionLogContext(record, {
+ requestedAction: action,
+ failedDoseIndex: doseIndex,
+ code: result.code,
+ }),
+ "[NotificationActions] Failed to record taken notification action"
+ );
+ const statusCode = result.code === "INVALID_DOSE" ? 400 : 409;
+ return reply.status(statusCode).send({ error: result.message, code: result.code });
+ }
+ }
+ } else {
+ await skipDosesForUser({ userId: record.group.userId, doseIds: record.doseIds });
+ }
+
+ await db
+ .update(notificationActionGroups)
+ .set({ resolvedAction: action, resolvedAt: new Date(), updatedAt: new Date() })
+ .where(eq(notificationActionGroups.id, record.group.id));
+ await db
+ .update(notificationActionTokens)
+ .set({ usedAt: new Date() })
+ .where(eq(notificationActionTokens.id, record.token.id));
+
+ request.log.info(
+ buildNotificationActionLogContext(record, { requestedAction: action }),
+ "[NotificationActions] Recorded notification action"
+ );
+
+ const recordedText = getActionRecordedText(language, action);
+ let replacedNtfyNotification = false;
+ const previousNtfyMessageId = record.group.ntfyOriginalMessageId.trim();
+
+ try {
+ const replacementResult = await replaceNtfyNotificationSequence({
+ userId: record.group.userId,
+ sequenceId: record.group.sequenceId,
+ language,
+ title: record.group.title,
+ originalMessage: record.group.message,
+ action,
+ viewUrl: record.viewUrl,
+ });
+ replacedNtfyNotification = replacementResult.replaced;
+
+ if (replacementResult.providerMessageId) {
+ await db
+ .update(notificationActionGroups)
+ .set({ ntfyOriginalMessageId: replacementResult.providerMessageId, updatedAt: new Date() })
+ .where(eq(notificationActionGroups.id, record.group.id));
+ }
+
+ if (
+ replacementResult.replaced &&
+ previousNtfyMessageId.length > 0 &&
+ previousNtfyMessageId !== replacementResult.providerMessageId
+ ) {
+ try {
+ await deleteNtfyNotificationSequence(record.group.userId, previousNtfyMessageId);
+ } catch (error) {
+ request.log.warn(
+ buildNotificationActionLogContext(record, {
+ requestedAction: action,
+ originalMessageId: previousNtfyMessageId,
+ error,
+ }),
+ "[NotificationActions] Failed to delete original ntfy notification after replacement"
+ );
+ }
+ }
+ } catch (error) {
+ request.log.warn(
+ buildNotificationActionLogContext(record, { requestedAction: action, error }),
+ "[NotificationActions] Failed to replace ntfy notification after resolved action"
+ );
+ }
+
+ if (!replacedNtfyNotification) {
+ try {
+ await deleteNtfyNotificationSequence(record.group.userId, record.group.sequenceId);
+ } catch (error) {
+ request.log.warn(
+ buildNotificationActionLogContext(record, { requestedAction: action, error }),
+ "[NotificationActions] Failed to delete ntfy notification after resolved action"
+ );
+ try {
+ await clearNtfyNotificationSequence(record.group.userId, record.group.sequenceId);
+ } catch (clearError) {
+ request.log.warn(
+ buildNotificationActionLogContext(record, { requestedAction: action, error: clearError }),
+ "[NotificationActions] Failed to clear ntfy notification after delete fallback"
+ );
+ }
+ }
+ }
+
+ if (wantsHtml(request)) {
+ return reply.type("text/html; charset=utf-8").send(
+ renderPage({
+ language,
+ title: record.group.title,
+ message: record.group.message,
+ bodyTitle: recordedText.bodyTitle,
+ bodyText: recordedText.bodyText,
+ viewUrl: record.viewUrl,
+ actionButtons: [],
+ })
+ );
+ }
+
+ return reply.send({ success: true, action });
+ }
+ );
+}
diff --git a/backend/src/routes/oidc.ts b/backend/src/routes/oidc.ts
index 93b41db..756f9c8 100644
--- a/backend/src/routes/oidc.ts
+++ b/backend/src/routes/oidc.ts
@@ -119,7 +119,7 @@ export async function oidcRoutes(app: FastifyInstance) {
return reply.redirect(authUrl.href);
} catch (err: unknown) {
request.log.error({ err }, "[OIDC] Login initialization failed");
- return reply.redirect(`${getFrontendUrl()}/?error=oidc_init_failed`);
+ return reply.redirect(getFrontendUrl());
}
}
);
@@ -151,25 +151,25 @@ export async function oidcRoutes(app: FastifyInstance) {
// Handle OIDC provider errors
if (error) {
app.log.warn({ error, errorDescription: error_description }, "[OIDC] Provider returned error");
- return reply.redirect(`${getFrontendUrl()}/?error=oidc_${error}`);
+ return reply.redirect(getFrontendUrl());
}
if (!code || !state) {
- return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_params`);
+ return reply.redirect(getFrontendUrl());
}
// Verify state
const storedState = request.unsignCookie(request.cookies.oidc_state || "");
if (!storedState.valid || storedState.value !== state) {
request.log.warn("[OIDC] State mismatch during callback validation");
- return reply.redirect(`${getFrontendUrl()}/?error=oidc_state_mismatch`);
+ return reply.redirect(getFrontendUrl());
}
// Get code verifier
const storedVerifier = request.unsignCookie(request.cookies.oidc_code_verifier || "");
if (!storedVerifier.valid || !storedVerifier.value) {
request.log.warn("[OIDC] Missing/invalid code verifier cookie");
- return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_verifier`);
+ return reply.redirect(getFrontendUrl());
}
try {
@@ -190,7 +190,7 @@ export async function oidcRoutes(app: FastifyInstance) {
const sub = tokens.claims()?.sub;
if (!sub) {
request.log.error("[OIDC] Missing sub claim in token response");
- return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_sub`);
+ return reply.redirect(getFrontendUrl());
}
const userInfo = await client.fetchUserInfo(config, tokens.access_token, sub);
@@ -208,7 +208,7 @@ export async function oidcRoutes(app: FastifyInstance) {
{ hasUsername: Boolean(username), hasOidcSubject: Boolean(oidcSubject) },
"[OIDC] Missing required user info"
);
- return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_user_info`);
+ return reply.redirect(getFrontendUrl());
}
// Clean cookies
@@ -219,7 +219,7 @@ export async function oidcRoutes(app: FastifyInstance) {
const user = await findOrCreateOIDCUser(username, oidcSubject, reply);
if (!user) {
- return reply.redirect(`${getFrontendUrl()}/?error=oidc_user_creation_failed`);
+ return reply.redirect(getFrontendUrl());
}
// Update last login
@@ -248,7 +248,7 @@ export async function oidcRoutes(app: FastifyInstance) {
return reply.redirect(`${frontendUrl}/dashboard`);
} catch (err: unknown) {
request.log.error({ err }, "[OIDC] Callback processing failed");
- return reply.redirect(`${getFrontendUrl()}/?error=oidc_callback_failed`);
+ return reply.redirect(getFrontendUrl());
}
}
);
diff --git a/backend/src/routes/planner.ts b/backend/src/routes/planner.ts
index 1441d6d..f5deca9 100644
--- a/backend/src/routes/planner.ts
+++ b/backend/src/routes/planner.ts
@@ -19,7 +19,7 @@ import {
type StockReminderItem as SharedStockReminderItem,
} from "../services/notifications/builders.js";
import { getSmtpConfig, sendEmailNotification, sendPushNotification } from "../services/notifications/delivery.js";
-import { escapeHtml, getPlannerUnit, isContainerPackage } from "../services/planner-service.js";
+import { escapeHtml, formatPlannerQuantity, getPlannerUnit, isContainerPackage } from "../services/planner-service.js";
import { updateReminderSentTime, updateUserReminderSentTime } from "../services/reminder-scheduler.js";
import type { AuthUser } from "../types/fastify.js";
import {
@@ -45,15 +45,28 @@ type PlannerRow = {
type SendEmailBody = {
email: string;
- from: string;
- until: string;
+ from?: string;
+ until?: string;
+ startDate?: string;
+ endDate?: string;
rows: PlannerRow[];
language?: Language; // Optional: passed from frontend for unauthenticated requests
};
+function resolvePlannerDateRange(body: SendEmailBody): { startDate: string; endDate: string } | null {
+ const startDate = body.startDate ?? body.from;
+ const endDate = body.endDate ?? body.until;
+ if (!startDate || !endDate) {
+ return null;
+ }
+
+ return { startDate, endDate };
+}
+
type LowStockItem = {
name: string;
medsLeft: number;
+ packageType?: string;
daysLeft: number | null;
depletionDate: string | null;
isCritical?: boolean;
@@ -164,11 +177,15 @@ export async function plannerRoutes(app: FastifyInstance) {
email: { type: "string" },
from: { type: "string" },
until: { type: "string" },
+ startDate: { type: "string", format: "date-time" },
+ endDate: { type: "string", format: "date-time" },
language: { type: "string" },
rows: { type: "array", items: plannerRowSchema },
},
example: {
email: "daniel@example.com",
+ startDate: "2026-03-11T00:00:00.000Z",
+ endDate: "2026-04-11T00:00:00.000Z",
from: "2026-03-11",
until: "2026-04-11",
language: "en",
@@ -197,13 +214,20 @@ export async function plannerRoutes(app: FastifyInstance) {
},
},
async (request, reply) => {
- const { email, from, until, rows, language: bodyLanguage } = request.body;
+ const { email, rows, language: bodyLanguage } = request.body;
+ const resolvedDateRange = resolvePlannerDateRange(request.body);
request.log.info({ email, rowCount: rows?.length ?? 0 }, "[Planner] Demand notification request received");
if (!rows || rows.length === 0) {
return reply.status(400).send({ error: "Missing planner data" });
}
+ if (!resolvedDateRange) {
+ return reply.status(400).send({ error: "Missing planner date range" });
+ }
+
+ const { startDate, endDate } = resolvedDateRange;
+
// Load user settings for notification channels
const userId = await getUserId(request);
const activeMeds = await db
@@ -245,14 +269,14 @@ export async function plannerRoutes(app: FastifyInstance) {
// Format dates for display - escape to prevent XSS even though toLocaleDateString should be safe
const fromDate = escapeHtml(
- new Date(from).toLocaleDateString(locale, {
+ new Date(startDate).toLocaleDateString(locale, {
year: "numeric",
month: "long",
day: "numeric",
})
);
const untilDate = escapeHtml(
- new Date(until).toLocaleDateString(locale, {
+ new Date(endDate).toLocaleDateString(locale, {
year: "numeric",
month: "long",
day: "numeric",
@@ -567,11 +591,10 @@ ${getFooterPlain(language)}`;
.map((med) => [med.name || med.genericName || "", normalizePackageType(med.packageType)] as const)
.filter(([name]) => name.length > 0)
);
- const filteredLowStock = lowStock.filter((item) => {
+ const filteredLowStock = lowStock.flatMap((item) => {
const packageType = activeMedicationByName.get(item.name);
- if (!packageType) return false;
- if (isTubePackageType(packageType)) return false;
- return true;
+ if (!packageType || isTubePackageType(packageType)) return [];
+ return [{ ...item, packageType }];
});
if (filteredLowStock.length === 0) {
request.log.warn("[ReminderManual] Stock reminder skipped: no active medications after filtering");
@@ -644,7 +667,7 @@ ${getFooterPlain(language)}`;
messageParts.push(`🚨 ${tr.push.criticalSection}:`);
criticalMeds.forEach((r) =>
messageParts.push(
- ` • ${r.name}: ${t(tr.push.pillsLeft, { count: r.medsLeft })}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}`
+ ` • ${r.name}: ${formatPlannerQuantity(r.packageType, r.medsLeft, tr)}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}`
)
);
}
@@ -653,7 +676,7 @@ ${getFooterPlain(language)}`;
messageParts.push(`⚠️ ${tr.push.lowStockSection}:`);
lowStockMeds.forEach((r) =>
messageParts.push(
- ` • ${r.name}: ${t(tr.push.pillsLeft, { count: r.medsLeft })}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}`
+ ` • ${r.name}: ${formatPlannerQuantity(r.packageType, r.medsLeft, tr)}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}`
)
);
}
@@ -734,12 +757,13 @@ ${getFooterPlain(language)}`;
const rowBg = isEmpty ? "#fef2f2" : nonEmptyBg;
const safeName = escapeHtml(row.name);
const safeMedsLeft = Number(row.medsLeft) || 0;
+ const safeQuantity = escapeHtml(formatPlannerQuantity(row.packageType, safeMedsLeft, tr));
const safeDaysLeft = Number(row.daysLeft) || 0;
const safeDepletionDate = row.depletionDate ? escapeHtml(String(row.depletionDate)) : "-";
return `
| ${statusIcon} ${safeName} |
- ${safeMedsLeft} |
+ ${safeQuantity} |
${safeDaysLeft} |
${isEmpty ? `${tr.stockReminder.now}` : safeDepletionDate} |
`;
diff --git a/backend/src/routes/refills.ts b/backend/src/routes/refills.ts
index d173ea9..5e3542e 100644
--- a/backend/src/routes/refills.ts
+++ b/backend/src/routes/refills.ts
@@ -12,16 +12,22 @@ import {
idParamsSchema,
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
-import { isAmountBasedPackageType, normalizePackageType } from "../utils/package-profiles.js";
+import {
+ isAmountBasedPackageType,
+ isDiscreteCountPackageType,
+ isPackageAmountPackageType,
+ normalizePackageType,
+} from "../utils/package-profiles.js";
const refillSchema = z
.object({
packsAdded: z.number().int().min(0).default(0),
loosePillsAdded: z.number().int().min(0).default(0),
+ quantityAdded: z.number().int().min(0).default(0),
usePrescription: z.boolean().default(false),
})
- .refine((data) => data.packsAdded > 0 || data.loosePillsAdded > 0, {
- message: "Must add at least one pack or some loose pills",
+ .refine((data) => data.packsAdded > 0 || data.loosePillsAdded > 0 || data.quantityAdded > 0, {
+ message: "Must add at least one pack or some quantity",
});
const refillBodyOpenApiSchema = {
@@ -29,12 +35,14 @@ const refillBodyOpenApiSchema = {
properties: {
packsAdded: { type: "integer", minimum: 0, default: 0 },
loosePillsAdded: { type: "integer", minimum: 0, default: 0 },
+ quantityAdded: { type: "integer", minimum: 0, default: 0 },
usePrescription: { type: "boolean", default: false },
},
- description: "Provide at least one pack or some loose pills.",
+ description: "Provide at least one pack or some quantity.",
example: {
packsAdded: 1,
loosePillsAdded: 4,
+ quantityAdded: 4,
usePrescription: true,
},
} as const;
@@ -49,6 +57,7 @@ const refillResponseSchema = {
id: { type: "number" },
packsAdded: { type: "integer" },
loosePillsAdded: { type: "integer" },
+ quantityAdded: { type: "number" },
totalPillsAdded: { type: "number" },
refillDate: { type: "string", format: "date-time" },
},
@@ -80,6 +89,7 @@ const refillHistoryItemSchema = {
id: { type: "number" },
packsAdded: { type: "integer" },
loosePillsAdded: { type: "integer" },
+ quantityAdded: { type: "number" },
totalPillsAdded: { type: "number" },
usedPrescription: { type: "boolean" },
refillDate: { type: "string", format: "date-time" },
@@ -136,11 +146,12 @@ export async function refillRoutes(app: FastifyInstance) {
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
if (!med) return reply.notFound("Medication not found");
- const { packsAdded, loosePillsAdded, usePrescription } = parsed.data;
+ const { packsAdded, loosePillsAdded, quantityAdded, usePrescription } = parsed.data;
const packageType = normalizePackageType(med.packageType);
- const isBottle = packageType === "bottle";
+ const isDiscreteCountPackage = isDiscreteCountPackageType(packageType);
const isAmountBased = isAmountBasedPackageType(packageType);
- const isCountBasedAmountPackage = isAmountBased && !isBottle;
+ const isPackageAmountPackage = isPackageAmountPackageType(packageType);
+ const pillsPerPack = isDiscreteCountPackage ? 0 : med.blistersPerPack * med.pillsPerBlister;
const configuredAmountPerPackage = Number(med.packageAmountValue ?? 0);
const fallbackAmountPerPackage = Math.max(
@@ -153,19 +164,17 @@ export async function refillRoutes(app: FastifyInstance) {
: fallbackAmountPerPackage;
const requestedPackAdds = Math.max(0, packsAdded);
- const requestedAmountAdds = Math.max(0, loosePillsAdded);
- const derivedCountFromAmount = Math.max(0, Math.round(requestedAmountAdds / amountPerPackage));
-
+ const requestedLooseAdds = Math.max(0, loosePillsAdded);
+ const requestedQuantityAdds = Math.max(0, quantityAdded > 0 ? quantityAdded : requestedLooseAdds);
let effectivePacksAdded = requestedPackAdds;
- if (isBottle) {
+ if (isDiscreteCountPackage) {
effectivePacksAdded = 0;
- } else if (isCountBasedAmountPackage) {
- effectivePacksAdded = Math.max(requestedPackAdds, derivedCountFromAmount);
}
- const effectiveLoosePillsAdded = isCountBasedAmountPackage
- ? effectivePacksAdded * amountPerPackage
- : requestedAmountAdds;
+ const effectiveLoosePillsAdded = isPackageAmountPackage ? requestedQuantityAdds : requestedLooseAdds;
const remainingPrescriptionRefills = med.prescriptionRemainingRefills ?? 0;
+ const totalPillsAdded = isAmountBased
+ ? effectiveLoosePillsAdded
+ : effectivePacksAdded * pillsPerPack + effectiveLoosePillsAdded;
if (effectivePacksAdded < 1 && effectiveLoosePillsAdded < 1) {
return reply.status(400).send({ error: "Must add at least one pack or some loose pills" });
@@ -178,29 +187,50 @@ export async function refillRoutes(app: FastifyInstance) {
if (remainingPrescriptionRefills <= 0) {
return reply.status(409).send({ error: "No remaining prescription refills" });
}
- if (!isBottle && effectivePacksAdded > remainingPrescriptionRefills) {
+ if (!isDiscreteCountPackage && effectivePacksAdded > remainingPrescriptionRefills) {
return reply.status(409).send({ error: "Packs to add exceed remaining prescription refills" });
}
}
- // Update medication stock
- const newPackCount = med.packCount + effectivePacksAdded;
- const newLooseTablets = med.looseTablets + effectiveLoosePillsAdded;
- const previousAmountBase = med.totalPills ?? med.looseTablets;
- const newTotalAmount = previousAmountBase + effectiveLoosePillsAdded;
+ const refillBaselineAt = new Date();
+ const baselineStockBeforeRefill = isAmountBased
+ ? med.looseTablets + (med.stockAdjustment ?? 0)
+ : med.packCount * pillsPerPack + med.looseTablets + (med.stockAdjustment ?? 0);
+ const targetCurrentStock = baselineStockBeforeRefill + totalPillsAdded;
+
+ // Update medication stock. Refill establishes a new persisted stock baseline and resets
+ // `lastStockCorrectionAt` so pre-refill dose history is ignored for future stock math.
+ let newPackCount = med.packCount + effectivePacksAdded;
+ let newLooseTablets = med.looseTablets + effectiveLoosePillsAdded;
+ let newStockAdjustment = med.stockAdjustment ?? 0;
+ let newTotalAmount = med.totalPills ?? med.looseTablets;
+
+ if (isDiscreteCountPackage) {
+ newLooseTablets = targetCurrentStock;
+ newTotalAmount = Math.max(newTotalAmount, targetCurrentStock);
+ newStockAdjustment = 0;
+ } else if (isPackageAmountPackage) {
+ newPackCount = Math.max(1, Math.ceil(targetCurrentStock / amountPerPackage));
+ newLooseTablets = targetCurrentStock;
+ newTotalAmount = targetCurrentStock;
+ newStockAdjustment = 0;
+ } else {
+ const structuralBaseAfterRefill = newPackCount * pillsPerPack + newLooseTablets;
+ newStockAdjustment = targetCurrentStock - structuralBaseAfterRefill;
+ }
let consumedRefills = 0;
if (usePrescription) {
- consumedRefills = isBottle ? 1 : effectivePacksAdded;
+ consumedRefills = isDiscreteCountPackage ? 1 : effectivePacksAdded;
}
const newRemainingRefills = usePrescription
? Math.max(0, remainingPrescriptionRefills - consumedRefills)
: (med.prescriptionRemainingRefills ?? null);
- const refillBaselineAt = new Date();
const updatePayload: {
packCount: number;
looseTablets: number;
+ stockAdjustment: number;
totalPills?: number;
packageAmountValue?: number;
prescriptionRemainingRefills: number | null;
@@ -209,12 +239,13 @@ export async function refillRoutes(app: FastifyInstance) {
} = {
packCount: newPackCount,
looseTablets: newLooseTablets,
+ stockAdjustment: newStockAdjustment,
prescriptionRemainingRefills: newRemainingRefills,
lastStockCorrectionAt: refillBaselineAt,
updatedAt: refillBaselineAt,
};
- if (isCountBasedAmountPackage) {
+ if (isPackageAmountPackage) {
updatePayload.totalPills = newTotalAmount;
updatePayload.packageAmountValue = amountPerPackage;
}
@@ -236,31 +267,20 @@ export async function refillRoutes(app: FastifyInstance) {
})
.returning();
- // Calculate pills added for response (packageType-aware)
- const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
- const totalPillsAdded = isAmountBased
- ? effectiveLoosePillsAdded
- : effectivePacksAdded * pillsPerPack + effectiveLoosePillsAdded;
- let newTotalPills = newPackCount * pillsPerPack + newLooseTablets + (med.stockAdjustment ?? 0);
- if (isCountBasedAmountPackage) {
- newTotalPills = (newTotalAmount ?? 0) + (med.stockAdjustment ?? 0);
- } else if (isBottle) {
- newTotalPills = newLooseTablets + (med.stockAdjustment ?? 0);
- }
-
return {
success: true,
refill: {
id: refill.id,
packsAdded: effectivePacksAdded,
loosePillsAdded: effectiveLoosePillsAdded,
+ quantityAdded: totalPillsAdded,
totalPillsAdded,
refillDate: refill.refillDate,
},
newStock: {
packCount: newPackCount,
looseTablets: newLooseTablets,
- totalPills: newTotalPills,
+ totalPills: targetCurrentStock,
},
prescription: {
used: usePrescription,
@@ -308,14 +328,15 @@ export async function refillRoutes(app: FastifyInstance) {
.orderBy(desc(refillHistory.refillDate));
const packageType = normalizePackageType(med.packageType);
- const isBottle = packageType === "bottle";
+ const isDiscreteCountPackage = isDiscreteCountPackageType(packageType);
const isAmountBased = isAmountBasedPackageType(packageType);
- const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
+ const pillsPerPack = isDiscreteCountPackage ? 0 : med.blistersPerPack * med.pillsPerBlister;
return refills.map((r) => ({
id: r.id,
packsAdded: r.packsAdded,
loosePillsAdded: r.loosePillsAdded,
+ quantityAdded: isAmountBased ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded,
totalPillsAdded: isAmountBased ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded,
usedPrescription: r.usedPrescription ?? false,
refillDate: r.refillDate,
diff --git a/backend/src/routes/report.ts b/backend/src/routes/report.ts
index efa75f6..3f88725 100644
--- a/backend/src/routes/report.ts
+++ b/backend/src/routes/report.ts
@@ -1,4 +1,4 @@
-import { and, eq } from "drizzle-orm";
+import { and, eq, gte, lt } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
@@ -12,9 +12,42 @@ import {
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
-const reportDataSchema = z.object({
- medicationIds: z.array(z.number().int().positive()).min(1).max(100),
-});
+const reportDataSchema = z
+ .object({
+ medicationIds: z.array(z.number().int().positive()).min(1).max(100),
+ startDate: z.string().datetime().optional(),
+ endDate: z.string().datetime().optional(),
+ takenByFilter: z.array(z.string().trim().min(1).max(100)).max(50).optional(),
+ })
+ .superRefine((value, ctx) => {
+ const hasStartDate = typeof value.startDate === "string";
+ const hasEndDate = typeof value.endDate === "string";
+
+ if (hasStartDate !== hasEndDate) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "startDate and endDate must be provided together",
+ path: hasStartDate ? ["endDate"] : ["startDate"],
+ });
+ return;
+ }
+
+ if (!hasStartDate || !hasEndDate) {
+ return;
+ }
+
+ const startDateValue = value.startDate!;
+ const endDateValue = value.endDate!;
+ const startDate = new Date(startDateValue);
+ const endDate = new Date(endDateValue);
+ if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime()) || endDate <= startDate) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Invalid date range",
+ path: ["endDate"],
+ });
+ }
+ });
const reportDataBodyOpenApiSchema = {
type: "object",
@@ -26,12 +59,65 @@ const reportDataBodyOpenApiSchema = {
maxItems: 100,
items: { type: "integer", minimum: 1 },
},
+ startDate: {
+ type: "string",
+ format: "date-time",
+ },
+ endDate: {
+ type: "string",
+ format: "date-time",
+ },
+ takenByFilter: {
+ type: "array",
+ maxItems: 50,
+ items: { type: "string", minLength: 1, maxLength: 100 },
+ },
},
example: {
medicationIds: [1, 3, 5],
+ startDate: "2026-05-01T00:00:00.000Z",
+ endDate: "2026-06-01T00:00:00.000Z",
+ takenByFilter: ["Daniel"],
},
} as const;
+const trackedDoseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
+
+function getPersonTagKey(value: string): string {
+ return value.trim().toLocaleLowerCase();
+}
+
+function matchesTakenByFilter(doseId: string, takenByFilter: Set | null): boolean {
+ if (!takenByFilter) return true;
+ const parts = doseId.split("-");
+ if (parts.length < 4) return false;
+ const takenBy = parts.at(-1)?.trim();
+ if (!takenBy) return false;
+ return takenByFilter.has(getPersonTagKey(takenBy));
+}
+
+function getDoseScheduledAtMs(doseId: string): number | null {
+ const match = trackedDoseIdPattern.exec(doseId);
+ if (!match) {
+ return null;
+ }
+
+ const scheduledAtMs = Number.parseInt(match[3], 10);
+ return Number.isNaN(scheduledAtMs) ? null : scheduledAtMs;
+}
+
+function isWithinDateRange(timestampMs: number | null, range: { startMs: number; endMs: number } | null): boolean {
+ if (!range) {
+ return true;
+ }
+
+ if (timestampMs === null) {
+ return false;
+ }
+
+ return timestampMs >= range.startMs && timestampMs < range.endMs;
+}
+
const reportDataResponseSchema = {
type: "object",
additionalProperties: {
@@ -39,7 +125,7 @@ const reportDataResponseSchema = {
properties: {
dosesTaken: { type: "integer" },
automaticDosesTaken: { type: "integer" },
- dosesDismissed: { type: "integer" },
+ dosesSkipped: { type: "integer" },
firstDoseAt: { type: "string" },
lastDoseAt: { type: "string" },
refills: {
@@ -49,6 +135,7 @@ const reportDataResponseSchema = {
properties: {
packsAdded: { type: "integer" },
loosePillsAdded: { type: "integer" },
+ quantityAdded: { type: "integer" },
usedPrescription: { type: "boolean" },
refillDate: { type: "string", format: "date-time" },
},
@@ -93,10 +180,29 @@ export async function reportRoutes(app: FastifyInstance) {
if (!parsed.success) return reply.status(400).send(parsed.error.format());
const userId = await getUserId(req, reply);
- const { medicationIds } = parsed.data;
+ const { medicationIds, startDate, endDate, takenByFilter } = parsed.data;
+ const normalizedTakenByFilter = takenByFilter?.length
+ ? new Set(takenByFilter.map((value) => getPersonTagKey(value)))
+ : null;
+ const dateRange =
+ startDate && endDate
+ ? {
+ startMs: new Date(startDate).getTime(),
+ endMs: new Date(endDate).getTime(),
+ }
+ : null;
// Verify all medications belong to this user
- const userMeds = await db.select({ id: medications.id }).from(medications).where(eq(medications.userId, userId));
+ const userMeds = await db
+ .select({
+ id: medications.id,
+ packageType: medications.packageType,
+ blistersPerPack: medications.blistersPerPack,
+ pillsPerBlister: medications.pillsPerBlister,
+ })
+ .from(medications)
+ .where(eq(medications.userId, userId));
+ const medMap = new Map(userMeds.map((med) => [med.id, med]));
const userMedIds = new Set(userMeds.map((m) => m.id));
for (const id of medicationIds) {
@@ -122,6 +228,8 @@ export async function reportRoutes(app: FastifyInstance) {
for (const dose of allDoses) {
const medId = Number.parseInt(dose.doseId.split("-")[0], 10);
if (Number.isNaN(medId) || !medicationIds.includes(medId)) continue;
+ if (!matchesTakenByFilter(dose.doseId, normalizedTakenByFilter)) continue;
+ if (!isWithinDateRange(getDoseScheduledAtMs(dose.doseId), dateRange)) continue;
if (!dosesByMed.has(medId)) dosesByMed.set(medId, []);
dosesByMed.get(medId)!.push({
takenAt: dose.takenAt,
@@ -136,10 +244,16 @@ export async function reportRoutes(app: FastifyInstance) {
{
dosesTaken: number;
automaticDosesTaken: number;
- dosesDismissed: number;
+ dosesSkipped: number;
firstDoseAt: string | null;
lastDoseAt: string | null;
- refills: { packsAdded: number; loosePillsAdded: number; usedPrescription: boolean; refillDate: string }[];
+ refills: {
+ packsAdded: number;
+ loosePillsAdded: number;
+ quantityAdded: number;
+ usedPrescription: boolean;
+ refillDate: string;
+ }[];
}
> = {};
@@ -147,25 +261,34 @@ export async function reportRoutes(app: FastifyInstance) {
const doses = dosesByMed.get(medId) ?? [];
const takenDoses = doses.filter((d) => !d.dismissed);
const automaticTakenDoses = takenDoses.filter((d) => d.takenSource === "automatic");
- const dismissedDoses = doses.filter((d) => d.dismissed);
+ const skippedDoses = doses.filter((d) => d.dismissed);
const sortedTaken = takenDoses.map((d) => d.takenAt.getTime()).sort((a, b) => a - b);
+ const medication = medMap.get(medId);
+ const pillsPerPack = Math.max(1, (medication?.blistersPerPack ?? 1) * (medication?.pillsPerBlister ?? 1));
+ const isAmountBased = medication?.packageType === "liquid_container" || medication?.packageType === "tube";
// Get refills for this medication scoped to the authenticated user.
+ const refillFilters = [eq(refillHistory.medicationId, medId), eq(refillHistory.userId, userId)];
+ if (dateRange) {
+ refillFilters.push(gte(refillHistory.refillDate, new Date(dateRange.startMs)));
+ refillFilters.push(lt(refillHistory.refillDate, new Date(dateRange.endMs)));
+ }
const refills = await db
.select()
.from(refillHistory)
- .where(and(eq(refillHistory.medicationId, medId), eq(refillHistory.userId, userId)));
+ .where(and(...refillFilters));
result[medId] = {
dosesTaken: takenDoses.length,
automaticDosesTaken: automaticTakenDoses.length,
- dosesDismissed: dismissedDoses.length,
+ dosesSkipped: skippedDoses.length,
firstDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[0]).toISOString() : null,
lastDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[sortedTaken.length - 1]).toISOString() : null,
refills: refills.map((r) => ({
packsAdded: r.packsAdded,
loosePillsAdded: r.loosePillsAdded,
+ quantityAdded: isAmountBased ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded,
usedPrescription: r.usedPrescription ?? false,
refillDate: r.refillDate instanceof Date ? r.refillDate.toISOString() : String(r.refillDate),
})),
diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts
index 167b523..806ae6f 100644
--- a/backend/src/routes/settings.ts
+++ b/backend/src/routes/settings.ts
@@ -2,8 +2,17 @@ import { eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { db } from "../db/client.js";
import { userSettings } from "../db/schema.js";
+import { getDateLocale, getFooterPlain, getTranslations, type Language, t } from "../i18n/translations.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
+import {
+ createTestNotificationActionContext,
+ storeNotificationActionGroupNtfyMessageId,
+} from "../services/notification-actions-service.js";
+import {
+ type PushNotificationOptions,
+ renderNotificationActionPayload,
+} from "../services/notifications/action-renderer.js";
import { getSmtpConfig, sendEmailNotification } from "../services/notifications/delivery.js";
import {
classifyTestEmailFailure,
@@ -70,36 +79,6 @@ const settingsErrorSchema = {
},
};
-type MailDeliveryInfo = {
- accepted?: unknown;
- rejected?: unknown;
- response?: unknown;
-};
-
-function normalizeRecipients(value: unknown): string[] {
- if (!Array.isArray(value)) return [];
- return value
- .map((entry) => (typeof entry === "string" ? entry : String(entry ?? "")))
- .map((entry) => entry.trim())
- .filter(Boolean);
-}
-
-function getDeliveryError(info: MailDeliveryInfo): string | null {
- const accepted = normalizeRecipients(info.accepted);
- const rejected = normalizeRecipients(info.rejected);
-
- if (accepted.length > 0) return null;
- if (rejected.length > 0) {
- return `SMTP rejected all recipients: ${rejected.join(", ")}`;
- }
-
- if (typeof info.response === "string" && info.response.trim()) {
- return `SMTP did not confirm accepted recipients. Response: ${info.response}`;
- }
-
- return "SMTP did not confirm accepted recipients.";
-}
-
function envInt(key: string, defaultVal: number): number {
const val = process.env[key];
if (val === undefined) return defaultVal;
@@ -107,6 +86,24 @@ function envInt(key: string, defaultVal: number): number {
return Number.isNaN(parsed) ? defaultVal : parsed;
}
+function getLanguage(language: string | null | undefined): Language {
+ return language === "de" ? "de" : "en";
+}
+
+function buildInteractiveTestPushNotification(language: Language): { title: string; message: string } {
+ const tr = getTranslations(language);
+ const reminderAt = new Date(Date.now() + 60 * 1000);
+ const reminderTime = new Intl.DateTimeFormat(getDateLocale(language), {
+ hour: "2-digit",
+ minute: "2-digit",
+ }).format(reminderAt);
+
+ return {
+ title: t(tr.push.intakeTitle, { minutes: 1 }),
+ message: `• MedAssist-ng Test: 1 ${tr.common.pill} (100 mg) @ ${reminderTime}\n\n---\n${getFooterPlain(language)}`,
+ };
+}
+
async function getOrCreateUserSettings(userId: number) {
let [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
@@ -552,14 +549,33 @@ export async function settingsRoutes(app: FastifyInstance) {
}
try {
+ const userId = await getUserId(request, reply);
+ const settings = await getOrCreateUserSettings(userId);
+ const language = getLanguage(settings.language);
+ const { title, message } = buildInteractiveTestPushNotification(language);
+ const actionContext = await createTestNotificationActionContext({
+ userId,
+ title,
+ message,
+ publicAppUrl: env.PUBLIC_APP_URL,
+ language,
+ });
const provider = getNotificationProvider(url);
- const result = await sendShoutrrrNotification(
- url,
- "MedAssist-ng Test",
- "This is a test notification from MedAssist-ng. If you received this, your notification configuration is working correctly!"
- );
+ const result = await sendShoutrrrNotification(url, title, message, {
+ actions: actionContext?.actions,
+ respondUrl: actionContext?.respondUrl,
+ viewUrl: actionContext?.viewUrl,
+ clickUrl: actionContext?.respondUrl ?? actionContext?.viewUrl,
+ sequenceId: actionContext?.sequenceId,
+ tags: ["pill"],
+ priority: 3,
+ });
if (result.success) {
+ if (actionContext?.groupId && result.providerMessageId) {
+ await storeNotificationActionGroupNtfyMessageId(actionContext.groupId, result.providerMessageId);
+ }
+
request.log.info({ provider }, "[Settings] Test push notification sent");
return reply.send({ success: true, message: "Test notification sent successfully" });
} else {
@@ -582,8 +598,9 @@ export async function settingsRoutes(app: FastifyInstance) {
export async function sendShoutrrrNotification(
urlStr: string,
title: string,
- message: string
-): Promise<{ success: boolean; error?: string }> {
+ message: string,
+ options: PushNotificationOptions = {}
+): Promise<{ success: boolean; error?: string; providerMessageId?: string }> {
try {
if (urlStr.startsWith("pushover://")) {
const pushoverAuthority = urlStr.slice("pushover://".length).split("/")[0] ?? "";
@@ -736,12 +753,13 @@ export async function sendShoutrrrNotification(
}
// Use ONLY the reconstructed URL from validation - never the original urlStr
- const { url: sanitizedUrl, isNtfy: _isNtfy, auth } = validation;
+ const { url: sanitizedUrl, isNtfy, auth } = validation;
let targetUrl: string;
const method = "POST";
let headers: Record = {};
let body: string | undefined;
+ const renderedPayload = renderNotificationActionPayload(urlStr, message, options);
// Remove emojis from title for header compatibility
const cleanTitle = title
@@ -786,19 +804,27 @@ export async function sendShoutrrrNotification(
// characters (umlauts, accents, etc.) through HTTP headers
const encodedTitle = `=?UTF-8?B?${Buffer.from(cleanTitle, "utf-8").toString("base64")}?=`;
headers = { Title: encodedTitle, Tags: "pill" };
- body = message;
+ body = renderedPayload.message;
// Add auth if present (extracted during sanitization)
if (auth) {
headers.Authorization = `Basic ${Buffer.from(`${auth.user}:${auth.pass}`).toString("base64")}`;
}
+
+ if (isNtfy) {
+ headers = { ...headers, ...renderedPayload.headers };
+ }
} else if (sanitizedUrl.startsWith("http://") || sanitizedUrl.startsWith("https://")) {
targetUrl = sanitizedUrl;
headers = { "Content-Type": "application/json" };
if (isDiscordWebhook) {
- body = JSON.stringify({ content: `${title}\n\n${message}` });
+ body = JSON.stringify({ content: `${title}\n\n${renderedPayload.message}` });
} else {
- body = JSON.stringify({ title, message, text: `${title}\n\n${message}` });
+ body = JSON.stringify({
+ title,
+ message: renderedPayload.message,
+ text: `${title}\n\n${renderedPayload.message}`,
+ });
}
} else {
return {
@@ -823,7 +849,17 @@ export async function sendShoutrrrNotification(
});
if (response.ok) {
- return { success: true };
+ let providerMessageId: string | undefined;
+ if (isNtfy) {
+ try {
+ const payload = (await response.json()) as { id?: unknown };
+ providerMessageId = typeof payload.id === "string" && payload.id.length > 0 ? payload.id : undefined;
+ } catch {
+ providerMessageId = undefined;
+ }
+ }
+
+ return { success: true, providerMessageId };
} else {
const errorText = await response.text();
return { success: false, error: `HTTP ${response.status}: ${errorText}` };
diff --git a/backend/src/routes/share.ts b/backend/src/routes/share.ts
index b27ea43..5f9f67a 100644
--- a/backend/src/routes/share.ts
+++ b/backend/src/routes/share.ts
@@ -1,5 +1,5 @@
import { randomBytes } from "node:crypto";
-import { and, eq } from "drizzle-orm";
+import { and, desc, eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
@@ -15,6 +15,7 @@ import {
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
import { isAmountBasedPackageType, normalizePackageType } from "../utils/package-profiles.js";
+import { redactTokenForLog } from "../utils/redaction.js";
import {
getAllTakenByForMedication,
parseIntakesJson,
@@ -28,6 +29,11 @@ import {
const createShareSchema = z.object({
takenBy: z.string().min(1, "takenBy is required"),
scheduleDays: z.number().int().min(1).max(365).default(30),
+ expiryDays: z
+ .union([z.number().int().min(1).max(365), z.null()])
+ .optional()
+ .default(null),
+ allowJournalNotes: z.boolean().optional().default(false),
});
const protectedEndpointSecurity: ReadonlyArray> = [
@@ -37,15 +43,59 @@ const protectedEndpointSecurity: ReadonlyArray
const shareTokenPattern = /^[a-f0-9]{16}$/;
+function toIsoTimestamp(value: Date | string | number | null | undefined): string | null {
+ if (value == null) {
+ return null;
+ }
+
+ try {
+ if (value instanceof Date) {
+ return Number.isNaN(value.getTime()) ? null : value.toISOString();
+ }
+
+ if (typeof value === "number" || (typeof value === "string" && /^\d+$/.test(value))) {
+ const numericValue = typeof value === "number" ? value : Number(value);
+ const timestampMs = numericValue < 1_000_000_000_000 ? numericValue * 1000 : numericValue;
+ const date = new Date(timestampMs);
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
+ }
+
+ const date = new Date(value);
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
+ } catch {
+ return null;
+ }
+}
+
+function resolveExpiryDate(expiryDays: number | null | undefined): Date | null {
+ if (expiryDays == null) {
+ return null;
+ }
+
+ return new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000);
+}
+
+function isExpiredTimestamp(value: Date | string | number | null | undefined): boolean {
+ const isoValue = toIsoTimestamp(value);
+ return isoValue != null && new Date(isoValue).getTime() < Date.now();
+}
+
const createShareBodyOpenApiSchema = {
type: "object",
properties: {
takenBy: { type: "string" },
scheduleDays: { type: "integer", minimum: 1, maximum: 365, default: 30 },
+ allowJournalNotes: { type: "boolean", default: false },
+ expiryDays: {
+ anyOf: [{ type: "integer", minimum: 1, maximum: 365 }, { type: "null" }],
+ default: null,
+ },
},
example: {
takenBy: "Daniel",
scheduleDays: 14,
+ allowJournalNotes: true,
+ expiryDays: 30,
},
} as const;
@@ -64,6 +114,7 @@ const shareReadResponseSchema = {
stockCalculationMode: { type: "string", enum: ["automatic", "manual"] },
upcomingTodayOnly: { type: "boolean" },
shareScheduleTodayOnly: { type: "boolean" },
+ allowJournalNotes: { type: "boolean" },
},
} as const;
@@ -96,6 +147,37 @@ const shareOverviewResponseSchema = {
},
} as const;
+const shareListResponseSchema = {
+ type: "object",
+ properties: {
+ shareLinks: {
+ type: "array",
+ items: {
+ type: "object",
+ properties: {
+ token: { type: "string" },
+ takenBy: { type: "string" },
+ scheduleDays: { type: "integer" },
+ createdAt: { type: "string", format: "date-time" },
+ expiresAt: { type: ["string", "null"], format: "date-time" },
+ allowJournalNotes: { type: "boolean" },
+ shareUrl: { type: "string" },
+ },
+ required: ["token", "takenBy", "scheduleDays", "createdAt", "expiresAt", "allowJournalNotes", "shareUrl"],
+ },
+ },
+ },
+ required: ["shareLinks"],
+} as const;
+
+const ownerTokenParamsSchema = {
+ type: "object",
+ properties: {
+ token: { type: "string" },
+ },
+ required: ["token"],
+} as const;
+
// Helper to get user ID from request
// Returns anonymous user ID when auth is disabled
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise {
@@ -146,11 +228,12 @@ export async function shareRoutes(app: FastifyInstance) {
},
async (request, reply) => {
const { token } = request.params;
+ const tokenRef = redactTokenForLog(token);
// Find share token
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
if (!share) {
- request.log.warn(`[Share] Invalid share token requested: token=${token}`);
+ request.log.warn(`[Share] Invalid share token requested: tokenRef=${tokenRef}`);
return reply.status(404).send({
error: "Share link not found",
code: "NOT_FOUND",
@@ -160,7 +243,7 @@ export async function shareRoutes(app: FastifyInstance) {
// Check if token has expired
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
request.log.warn(
- `[Share] Expired token requested: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}`
+ `[Share] Expired token requested: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}`
);
// Get the username of the owner to show in the expired message
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
@@ -255,6 +338,7 @@ export async function shareRoutes(app: FastifyInstance) {
takenBy: share.takenBy,
sharedBy: owner?.username ?? null,
scheduleDays: share.scheduleDays,
+ allowJournalNotes: share.allowJournalNotes ?? false,
medications: medicationsWithBlisters,
shareMedicationOverview,
medicationOverview,
@@ -298,20 +382,21 @@ export async function shareRoutes(app: FastifyInstance) {
reply.header("Cache-Control", "no-store");
const { token } = request.params;
+ const tokenRef = redactTokenForLog(token);
if (!shareTokenPattern.test(token)) {
- request.log.warn(`[ShareOverview] Rejected invalid token format: token=${token}`);
+ request.log.warn(`[ShareOverview] Rejected invalid token format: tokenRef=${tokenRef}`);
return reply.status(404).send({ error: "not_found" });
}
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
if (!share) {
- request.log.warn(`[ShareOverview] Unknown token requested: token=${token}`);
+ request.log.warn(`[ShareOverview] Unknown token requested: tokenRef=${tokenRef}`);
return reply.status(404).send({ error: "not_found" });
}
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
request.log.warn(
- `[ShareOverview] Expired token requested: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}`
+ `[ShareOverview] Expired token requested: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}`
);
return reply.status(410).send({
error: "expired",
@@ -371,6 +456,7 @@ export async function shareRoutes(app: FastifyInstance) {
reused: { type: "boolean" },
token: { type: "string" },
shareUrl: { type: "string" },
+ allowJournalNotes: { type: "boolean" },
expiresAt: { type: ["string", "null"] },
},
},
@@ -385,12 +471,13 @@ export async function shareRoutes(app: FastifyInstance) {
const parsed = createShareSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
- error: parsed.error.errors[0]?.message ?? "Invalid input",
+ error: parsed.error.issues[0]?.message ?? "Invalid input",
code: "VALIDATION_ERROR",
});
}
- const { takenBy, scheduleDays } = parsed.data;
+ const { takenBy, scheduleDays, expiryDays, allowJournalNotes } = parsed.data;
+ const expiresAt = resolveExpiryDate(expiryDays);
// Check if user has medications for this takenBy (search in both medication-level and intake-level)
const allMeds = await db
@@ -422,43 +509,136 @@ export async function shareRoutes(app: FastifyInstance) {
.where(and(eq(shareTokens.userId, userId), eq(shareTokens.takenBy, takenBy)));
if (existingShare) {
- await db.update(shareTokens).set({ scheduleDays, expiresAt: null }).where(eq(shareTokens.id, existingShare.id));
+ const existingTokenRef = redactTokenForLog(existingShare.token);
+ await db
+ .update(shareTokens)
+ .set({ scheduleDays, expiresAt, allowJournalNotes })
+ .where(eq(shareTokens.id, existingShare.id));
request.log.info(
- `[Share] Reused existing share token: token=${existingShare.token}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}`
+ `[Share] Reused existing share token: tokenRef=${existingTokenRef}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}, allowJournalNotes=${allowJournalNotes}, expiresAt=${expiresAt?.toISOString() ?? "never"}`
);
return {
reused: true,
token: existingShare.token,
shareUrl: `/share/${existingShare.token}`,
- expiresAt: null,
+ allowJournalNotes,
+ expiresAt: toIsoTimestamp(expiresAt),
};
}
const token = randomBytes(8).toString("hex");
+ const tokenRef = redactTokenForLog(token);
await db.insert(shareTokens).values({
userId,
token,
takenBy,
scheduleDays,
- expiresAt: null,
+ allowJournalNotes,
+ expiresAt,
});
request.log.info(
- `[Share] Created new share token: token=${token}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}`
+ `[Share] Created new share token: tokenRef=${tokenRef}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}, allowJournalNotes=${allowJournalNotes}, expiresAt=${expiresAt?.toISOString() ?? "never"}`
);
return {
reused: false,
token,
shareUrl: `/share/${token}`,
- expiresAt: null,
+ allowJournalNotes,
+ expiresAt: toIsoTimestamp(expiresAt),
};
}
);
+ // ---------------------------------------------------------------------------
+ // GET /share - PROTECTED: List active share links for current owner
+ // ---------------------------------------------------------------------------
+ app.get(
+ "/share",
+ {
+ preHandler: requireAuth,
+ schema: {
+ tags: ["share"],
+ security: protectedEndpointSecurity,
+ response: {
+ 200: shareListResponseSchema,
+ 401: genericErrorSchema,
+ },
+ },
+ },
+ async (request, reply) => {
+ const userId = await getUserId(request, reply);
+ const shares = await db
+ .select()
+ .from(shareTokens)
+ .where(eq(shareTokens.userId, userId))
+ .orderBy(desc(shareTokens.createdAt));
+
+ return {
+ shareLinks: shares
+ .filter((share) => !isExpiredTimestamp(share.expiresAt))
+ .map((share) => ({
+ token: share.token,
+ takenBy: share.takenBy,
+ scheduleDays: share.scheduleDays,
+ createdAt: toIsoTimestamp(share.createdAt) ?? new Date().toISOString(),
+ expiresAt: toIsoTimestamp(share.expiresAt),
+ allowJournalNotes: share.allowJournalNotes ?? false,
+ shareUrl: `/share/${share.token}`,
+ })),
+ };
+ }
+ );
+
+ // ---------------------------------------------------------------------------
+ // DELETE /share/:token - PROTECTED: Revoke an existing share link
+ // ---------------------------------------------------------------------------
+ app.delete<{ Params: { token: string } }>(
+ "/share/:token",
+ {
+ preHandler: requireAuth,
+ schema: {
+ tags: ["share"],
+ security: protectedEndpointSecurity,
+ params: ownerTokenParamsSchema,
+ response: {
+ 204: { type: "null" },
+ 401: genericErrorSchema,
+ 404: genericErrorSchema,
+ },
+ },
+ },
+ async (request, reply) => {
+ const userId = await getUserId(request, reply);
+ const { token } = request.params;
+ const tokenRef = redactTokenForLog(token);
+
+ const [share] = await db
+ .select()
+ .from(shareTokens)
+ .where(and(eq(shareTokens.userId, userId), eq(shareTokens.token, token)));
+
+ if (!share) {
+ return reply.status(404).send({
+ error: "Share link not found",
+ code: "NOT_FOUND",
+ });
+ }
+
+ await db.delete(shareTokens).where(eq(shareTokens.id, share.id));
+
+ request.log.info(
+ `[Share] Revoked share token: tokenRef=${tokenRef}, ownerUserId=${userId}, takenBy=${share.takenBy}`
+ );
+
+ return reply.status(204).send();
+ }
+ );
+
// ---------------------------------------------------------------------------
// GET /share/people - PROTECTED: Get list of unique takenBy values
// ---------------------------------------------------------------------------
diff --git a/backend/src/services/dose-tracking-service.ts b/backend/src/services/dose-tracking-service.ts
new file mode 100644
index 0000000..dcb14d7
--- /dev/null
+++ b/backend/src/services/dose-tracking-service.ts
@@ -0,0 +1,280 @@
+import { and, eq } from "drizzle-orm";
+import { db } from "../db/client.js";
+import { doseTracking, medications, userSettings } from "../db/schema.js";
+import { parseIntakesJson, parseLocalDateTime } from "../utils/scheduler-utils.js";
+import { computeMedicationCurrentStock } from "./current-stock.js";
+
+const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
+
+type ParsedDoseId = {
+ medicationId: number;
+ intakeIndex: number;
+ timestampMs: number;
+ personSuffix: string | null;
+};
+
+export type DoseTrackingSource = "manual" | "automatic" | "notification";
+
+export type MarkDoseTakenResult =
+ | {
+ success: true;
+ status: "marked" | "already_taken";
+ }
+ | {
+ success: false;
+ code: "OUT_OF_STOCK" | "INVALID_DOSE" | "ALREADY_SKIPPED";
+ message: string;
+ };
+
+export type DismissDosesResult = {
+ success: true;
+ dismissedCount: number;
+ alreadyTakenCount: number;
+};
+
+export type SkipDosesResult = {
+ success: true;
+ skippedCount: number;
+ alreadySkippedCount: number;
+ switchedFromTakenCount: number;
+};
+
+function parseDoseId(doseId: string): ParsedDoseId | null {
+ const match = doseIdPattern.exec(doseId);
+ if (!match) {
+ return null;
+ }
+
+ const medicationId = Number.parseInt(match[1], 10);
+ const intakeIndex = Number.parseInt(match[2], 10);
+ const timestampMs = Number.parseInt(match[3], 10);
+ const personSuffix = match[4] ? match[4].trim() : null;
+
+ if (Number.isNaN(medicationId) || Number.isNaN(intakeIndex) || Number.isNaN(timestampMs) || intakeIndex < 0) {
+ return null;
+ }
+
+ return {
+ medicationId,
+ intakeIndex,
+ timestampMs,
+ personSuffix,
+ };
+}
+
+function hasRealTakenTimestamp(takenAt: Date | null): boolean {
+ return takenAt instanceof Date && takenAt.getTime() > 0;
+}
+
+async function isDoseOutOfStock(options: { userId: number; doseId: string }): Promise {
+ const parsedDose = parseDoseId(options.doseId);
+ if (!parsedDose) {
+ return false;
+ }
+
+ const [medication] = await db
+ .select()
+ .from(medications)
+ .where(and(eq(medications.id, parsedDose.medicationId), eq(medications.userId, options.userId)));
+
+ if (!medication) {
+ return false;
+ }
+
+ const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, options.userId));
+ const stockCalculationMode = (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic";
+
+ const intakes = parseIntakesJson(
+ medication.intakesJson,
+ { usageJson: medication.usageJson, everyJson: medication.everyJson, startJson: medication.startJson },
+ medication.intakeRemindersEnabled ?? false
+ );
+ const intake = intakes[parsedDose.intakeIndex];
+
+ const scheduledOccurrenceMs = intake
+ ? (() => {
+ const doseDate = new Date(parsedDose.timestampMs);
+ const intakeStart = parseLocalDateTime(intake.start);
+ return new Date(
+ doseDate.getFullYear(),
+ doseDate.getMonth(),
+ doseDate.getDate(),
+ intakeStart.getHours(),
+ intakeStart.getMinutes(),
+ intakeStart.getSeconds(),
+ intakeStart.getMilliseconds()
+ ).getTime();
+ })()
+ : parsedDose.timestampMs;
+
+ const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, options.userId));
+ const stockBeforeDoseMs = Math.max(0, scheduledOccurrenceMs - 1);
+
+ return (
+ computeMedicationCurrentStock({
+ medication,
+ doses,
+ stockCalculationMode,
+ nowMs: stockBeforeDoseMs,
+ }) <= 0
+ );
+}
+
+export async function markDoseTakenForUser(input: {
+ userId: number;
+ doseId: string;
+ source: DoseTrackingSource;
+ markedBy?: string | null;
+}): Promise {
+ const parsedDose = parseDoseId(input.doseId);
+ if (!parsedDose) {
+ return {
+ success: false,
+ code: "INVALID_DOSE",
+ message: "Invalid dose ID",
+ };
+ }
+
+ const [existing] = await db
+ .select()
+ .from(doseTracking)
+ .where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, input.doseId)));
+
+ if (existing && !existing.dismissed) {
+ return { success: true, status: "already_taken" };
+ }
+
+ if (existing?.dismissed && hasRealTakenTimestamp(existing.takenAt)) {
+ return { success: true, status: "already_taken" };
+ }
+
+ if (existing?.dismissed) {
+ return {
+ success: false,
+ code: "ALREADY_SKIPPED",
+ message: "Dose is already skipped",
+ };
+ }
+
+ const outOfStock = await isDoseOutOfStock({ userId: input.userId, doseId: input.doseId });
+ if (outOfStock) {
+ return {
+ success: false,
+ code: "OUT_OF_STOCK",
+ message: "Medication is out of stock",
+ };
+ }
+
+ await db.insert(doseTracking).values({
+ userId: input.userId,
+ doseId: input.doseId,
+ takenAt: new Date(),
+ markedBy: input.markedBy ?? null,
+ takenSource: input.source,
+ dismissed: false,
+ });
+
+ return { success: true, status: "marked" };
+}
+
+export async function skipDosesForUser(input: { userId: number; doseIds: string[] }): Promise {
+ let skippedCount = 0;
+ let alreadySkippedCount = 0;
+ let switchedFromTakenCount = 0;
+
+ for (const doseId of input.doseIds) {
+ const [existing] = await db
+ .select()
+ .from(doseTracking)
+ .where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, doseId)));
+
+ if (!existing) {
+ await db.insert(doseTracking).values({
+ userId: input.userId,
+ doseId,
+ markedBy: null,
+ takenAt: new Date(0),
+ dismissed: true,
+ });
+ skippedCount++;
+ continue;
+ }
+
+ if (existing.dismissed) {
+ alreadySkippedCount++;
+ continue;
+ }
+
+ if (hasRealTakenTimestamp(existing.takenAt)) {
+ switchedFromTakenCount++;
+ }
+
+ await db
+ .update(doseTracking)
+ .set({
+ dismissed: true,
+ takenAt: new Date(0),
+ takenSource: "manual",
+ markedBy: null,
+ })
+ .where(eq(doseTracking.id, existing.id));
+ skippedCount++;
+ }
+
+ return {
+ success: true,
+ skippedCount,
+ alreadySkippedCount,
+ switchedFromTakenCount,
+ };
+}
+
+export async function dismissDosesForUser(input: { userId: number; doseIds: string[] }): Promise {
+ let dismissedCount = 0;
+ let alreadyTakenCount = 0;
+
+ for (const doseId of input.doseIds) {
+ const [existing] = await db
+ .select()
+ .from(doseTracking)
+ .where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, doseId)));
+
+ if (!existing) {
+ await db.insert(doseTracking).values({
+ userId: input.userId,
+ doseId,
+ markedBy: null,
+ takenAt: new Date(0),
+ dismissed: true,
+ });
+ dismissedCount++;
+ continue;
+ }
+
+ if (existing.dismissed) {
+ continue;
+ }
+
+ if (hasRealTakenTimestamp(existing.takenAt)) {
+ alreadyTakenCount++;
+ continue;
+ }
+
+ await db
+ .update(doseTracking)
+ .set({
+ dismissed: true,
+ takenAt: new Date(0),
+ takenSource: "manual",
+ markedBy: null,
+ })
+ .where(eq(doseTracking.id, existing.id));
+ dismissedCount++;
+ }
+
+ return {
+ success: true,
+ dismissedCount,
+ alreadyTakenCount,
+ };
+}
diff --git a/backend/src/services/intake-journal-export.ts b/backend/src/services/intake-journal-export.ts
new file mode 100644
index 0000000..b5a96e4
--- /dev/null
+++ b/backend/src/services/intake-journal-export.ts
@@ -0,0 +1,90 @@
+import { eq } from "drizzle-orm";
+import { db } from "../db/client.js";
+import { intakeJournal } from "../db/schema.js";
+
+type IntakeJournalWriteDatabase = Pick;
+
+export type IntakeJournalExportPayload = {
+ journalNote: string;
+ journalCreatedAt?: string | null;
+ journalUpdatedAt?: string | null;
+};
+
+function toIsoStringOrNull(value: Date | string | number | null | undefined): string | null {
+ if (!value) {
+ return null;
+ }
+
+ try {
+ if (value instanceof Date) {
+ return Number.isNaN(value.getTime()) ? null : value.toISOString();
+ }
+
+ const parsed = new Date(value);
+ return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
+ } catch {
+ return null;
+ }
+}
+
+function toDateOrFallback(value: string | null | undefined, fallback: Date): Date {
+ if (!value) {
+ return fallback;
+ }
+
+ try {
+ const parsed = new Date(value);
+ return Number.isNaN(parsed.getTime()) ? fallback : parsed;
+ } catch {
+ return fallback;
+ }
+}
+
+export async function listIntakeJournalExportPayloadsForUser(
+ userId: number
+): Promise