Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9984392b76 | |||
| 571d94bf7e | |||
| ac4b8151e4 | |||
| b2026637db | |||
| 99ef5bd622 | |||
| 1dcd333fde | |||
| 9ed039724e | |||
| 156e54f0ea | |||
| 47e8dfe9bc | |||
| aed0b20875 | |||
| fcd1b79c56 | |||
| e725700d10 | |||
| 8685e802cd | |||
| 1793f636bf | |||
| 9cf931f243 | |||
| 85f4d2dd21 | |||
| 01283ebd15 | |||
| 18bcb96869 | |||
| d516bdea7d | |||
| cab0fcbba7 | |||
| ecdb9bcbe0 | |||
| 9b0d8037e7 | |||
| a4d1dd215a |
@@ -7,6 +7,8 @@
|
||||
- **NEVER create PRs without explicit permission**: Do NOT create Pull Requests, push branches, or merge code unless the user explicitly asks for it. Always present changes and wait for the user to confirm before any git operations that affect the remote repository.
|
||||
- **No temporary files**: Delete temporary scripts/files immediately after use. Do not commit temporary debug scripts, test files, or one-off utilities to the repository.
|
||||
- **Clean workspace**: Always clean up after yourself. If you create a file for a specific task, delete it once done.
|
||||
- **Remove old code when re-implementing**: When fixing a bug or re-implementing a feature that didn't work, ALWAYS remove the old/broken code completely. Never leave dead code, unused functions, or obsolete implementations in the codebase.
|
||||
- **Tests are mandatory**: Every new feature and every bug fix MUST have corresponding tests. When modifying existing features, update or add tests accordingly. If old tests become obsolete due to code changes, remove or update them.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
@@ -193,19 +195,14 @@ gh pr merge --squash --delete-branch
|
||||
|
||||
### Release Workflow (MANDATORY for minor/major releases)
|
||||
|
||||
The `main` branch is protected - releases must go through the automated release script.
|
||||
The `main` branch is protected - releases are created via GitHub's release UI or API.
|
||||
|
||||
**Release Process:**
|
||||
```bash
|
||||
# 1. Run release script (creates PR, waits for CI, merges, creates tag)
|
||||
./scripts/release.sh [patch|minor|major]
|
||||
|
||||
# 2. GitHub Actions creates a DRAFT release automatically
|
||||
# 3. User asks AI to write release notes:
|
||||
# "Write the release notes for vX.Y.Z"
|
||||
# 4. AI writes descriptive release notes following the style guide below
|
||||
# 5. User publishes the draft release with the written notes
|
||||
```
|
||||
1. Create a new release on GitHub with tag `vX.Y.Z`
|
||||
2. **Automatic Version Bump**: A GitHub Action (`version-bump.yml`) automatically updates `package.json` versions to match the release tag
|
||||
3. User asks AI to write release notes: "Write the release notes for vX.Y.Z"
|
||||
4. AI writes descriptive release notes following the style guide below
|
||||
5. User publishes the release with the written notes
|
||||
|
||||
> ⚠️ **MANDATORY for minor and major releases**: The AI assistant MUST write proper descriptive release notes!
|
||||
> Do NOT just publish the auto-generated commit list. Follow the process above.
|
||||
@@ -503,6 +500,7 @@ Example: `5-0-1735344000000` = Medication 5, Blister 0, timestamp
|
||||
- **API responses**: Return objects directly, Fastify serializes to JSON
|
||||
- **Environment**: Copy `.env.example` → `.env`, secrets must be 10+ chars
|
||||
- **i18n**: All UI text via `t('key')` function, translations in `frontend/src/i18n/*.json`
|
||||
- **UI Consistency**: Always use existing components for modals, buttons, and forms. For confirmation dialogs, use `ConfirmModal` component. Never create inline modals with custom button styling - all UI elements must match the existing design system. When adding new sections to existing components, ensure font sizes, spacing, margins, and button styles match exactly with other sections. Check existing CSS classes before creating new ones.
|
||||
|
||||
## Database Schema Changes (IMPORTANT: Backward Compatibility!)
|
||||
|
||||
|
||||
@@ -137,13 +137,28 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0 # Fetch all history for changelog generation
|
||||
|
||||
- name: Check if release exists
|
||||
id: check_release
|
||||
run: |
|
||||
CURRENT_TAG=${GITHUB_REF#refs/tags/}
|
||||
if gh release view "$CURRENT_TAG" &>/dev/null; then
|
||||
echo "exists=true" >> $GITHUB_OUTPUT
|
||||
echo "Release $CURRENT_TAG already exists, skipping creation"
|
||||
else
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Get previous tag
|
||||
if: steps.check_release.outputs.exists == 'false'
|
||||
id: prev_tag
|
||||
run: |
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
|
||||
echo "tag=${PREV_TAG}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate changelog
|
||||
if: steps.check_release.outputs.exists == 'false'
|
||||
id: changelog
|
||||
run: |
|
||||
CURRENT_TAG=${GITHUB_REF#refs/tags/}
|
||||
@@ -172,6 +187,7 @@ jobs:
|
||||
echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/${PREV_TAG}...${CURRENT_TAG}" >> changelog.md
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: steps.check_release.outputs.exists == 'false'
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
body_path: changelog.md
|
||||
|
||||
@@ -35,6 +35,9 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: TypeScript type check
|
||||
run: npx tsc --noEmit
|
||||
|
||||
@@ -75,5 +78,8 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: TypeScript type check & build
|
||||
run: npm run build
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
name: Update Test Badges
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'backend/src/**'
|
||||
- 'frontend/src/**'
|
||||
- 'backend/package.json'
|
||||
- 'frontend/package.json'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
update-badges:
|
||||
name: Update Test Count Badges
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
|
||||
- name: Install backend dependencies
|
||||
working-directory: backend
|
||||
run: npm ci
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: frontend
|
||||
run: npm ci
|
||||
|
||||
- name: Run backend tests and capture count
|
||||
id: backend-tests
|
||||
working-directory: backend
|
||||
timeout-minutes: 5
|
||||
env:
|
||||
CI: true
|
||||
run: |
|
||||
OUTPUT=$(npm run test:run 2>&1) || true
|
||||
echo "$OUTPUT"
|
||||
# Extract "Tests X passed" from output
|
||||
PASSED=$(echo "$OUTPUT" | grep -oP 'Tests\s+\K\d+(?=\s+passed)' | tail -1)
|
||||
echo "count=$PASSED" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Run frontend tests and capture count
|
||||
id: frontend-tests
|
||||
working-directory: frontend
|
||||
timeout-minutes: 5
|
||||
env:
|
||||
CI: true
|
||||
run: |
|
||||
OUTPUT=$(npm run test:run 2>&1) || true
|
||||
echo "$OUTPUT"
|
||||
# Extract "Tests X passed" from output
|
||||
PASSED=$(echo "$OUTPUT" | grep -oP 'Tests\s+\K\d+(?=\s+passed)' | tail -1)
|
||||
echo "count=$PASSED" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Update README badges
|
||||
run: |
|
||||
BACKEND_COUNT="${{ steps.backend-tests.outputs.count }}"
|
||||
FRONTEND_COUNT="${{ steps.frontend-tests.outputs.count }}"
|
||||
|
||||
echo "Backend tests: $BACKEND_COUNT"
|
||||
echo "Frontend tests: $FRONTEND_COUNT"
|
||||
|
||||
# Only update if we got valid counts
|
||||
if [[ -n "$BACKEND_COUNT" && -n "$FRONTEND_COUNT" ]]; then
|
||||
# URL encode the slash for shields.io
|
||||
BACKEND_BADGE="https://img.shields.io/badge/Backend_Tests-${BACKEND_COUNT}%2F${BACKEND_COUNT}-brightgreen?logo=vitest"
|
||||
FRONTEND_BADGE="https://img.shields.io/badge/Frontend_Tests-${FRONTEND_COUNT}%2F${FRONTEND_COUNT}-brightgreen?logo=vitest"
|
||||
|
||||
# Update README using sed
|
||||
sed -i "s|https://img.shields.io/badge/Backend_Tests-[^\"]*|$BACKEND_BADGE|g" README.md
|
||||
sed -i "s|https://img.shields.io/badge/Frontend_Tests-[^\"]*|$FRONTEND_BADGE|g" README.md
|
||||
|
||||
echo "Updated badges in README.md"
|
||||
else
|
||||
echo "Could not extract test counts, skipping update"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
- name: Check for changes
|
||||
id: git-check
|
||||
run: |
|
||||
git diff --quiet README.md || echo "changed=true" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Commit and push if changed
|
||||
if: steps.git-check.outputs.changed == 'true'
|
||||
run: |
|
||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
git add README.md
|
||||
git commit -m "chore: update test count badges [skip ci]"
|
||||
git push
|
||||
@@ -0,0 +1,57 @@
|
||||
name: Version Bump on Release
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
version-bump:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Get version from tag
|
||||
id: version
|
||||
run: |
|
||||
# Extract version from tag (e.g., v1.6.0 -> 1.6.0)
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Extracted version: $VERSION"
|
||||
|
||||
- name: Update package.json versions
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
|
||||
# Update backend/package.json
|
||||
jq --arg v "$VERSION" '.version = $v' backend/package.json > backend/package.json.tmp
|
||||
mv backend/package.json.tmp backend/package.json
|
||||
|
||||
# Update frontend/package.json
|
||||
jq --arg v "$VERSION" '.version = $v' frontend/package.json > frontend/package.json.tmp
|
||||
mv frontend/package.json.tmp frontend/package.json
|
||||
|
||||
echo "Updated versions to $VERSION"
|
||||
cat backend/package.json | head -5
|
||||
cat frontend/package.json | head -5
|
||||
|
||||
- name: Commit and push
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
git add backend/package.json frontend/package.json
|
||||
|
||||
# Only commit if there are changes
|
||||
if git diff --staged --quiet; then
|
||||
echo "No version changes needed"
|
||||
else
|
||||
git commit -m "chore: bump version to ${{ steps.version.outputs.version }} [skip ci]"
|
||||
git push origin main
|
||||
fi
|
||||
@@ -71,4 +71,7 @@ Thumbs.db
|
||||
*.local
|
||||
.cache/
|
||||
.turbo/
|
||||
.roo/
|
||||
.roomodes
|
||||
AGENTS.md
|
||||
docs/TECH_STACK.md
|
||||
@@ -0,0 +1 @@
|
||||
npx lint-staged
|
||||
@@ -17,6 +17,11 @@
|
||||
<img src="https://img.shields.io/badge/Docker-Ready-2496ED?logo=docker" alt="Docker" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Backend_Tests-454%2F454-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
||||
<img src="https://img.shields.io/badge/Frontend_Tests-611%2F611-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
||||
</p>
|
||||
|
||||
### 🤖 AI-Generated Code
|
||||
|
||||
> This app was 100% coded with Claude Opus 4.5. Use at your own risk.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE `medications` ADD `dismissed_until` text;--> statement-breakpoint
|
||||
ALTER TABLE `user_settings` ADD `last_reminder_med_name` text;--> statement-breakpoint
|
||||
ALTER TABLE `user_settings` ADD `last_reminder_taken_by` text;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Add package type support (blister vs bottle)
|
||||
ALTER TABLE medications ADD COLUMN package_type TEXT DEFAULT 'blister' NOT NULL;
|
||||
ALTER TABLE medications ADD COLUMN total_pills INTEGER;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Add dose_unit column and intakes JSON array for per-intake takenBy support
|
||||
ALTER TABLE `medications` ADD `dose_unit` text(20) DEFAULT 'mg';--> statement-breakpoint
|
||||
ALTER TABLE `medications` ADD `intakes_json` text DEFAULT '[]' NOT NULL;
|
||||
@@ -0,0 +1,855 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "4f1d8273-1e60-4da1-9bfc-bd51c2784836",
|
||||
"prevId": "098ee506-e43d-4ccb-bee5-c387905695ab",
|
||||
"tables": {
|
||||
"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
|
||||
},
|
||||
"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": "'[]'"
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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": "'[]'"
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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'"
|
||||
},
|
||||
"stock_calculation_mode": {
|
||||
"name": "stock_calculation_mode",
|
||||
"type": "text(20)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'automatic'"
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,886 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "fb61e5fd-152d-4e61-8836-e2fd1d28e3f0",
|
||||
"prevId": "4f1d8273-1e60-4da1-9bfc-bd51c2784836",
|
||||
"tables": {
|
||||
"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
|
||||
},
|
||||
"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'"
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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'"
|
||||
},
|
||||
"stock_calculation_mode": {
|
||||
"name": "stock_calculation_mode",
|
||||
"type": "text(20)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'automatic'"
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,27 @@
|
||||
"when": 1768736677092,
|
||||
"tag": "0002_add_last_stock_correction_at",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1769354512857,
|
||||
"tag": "0003_add_reminder_info_columns",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1769886564000,
|
||||
"tag": "0004_add_package_type",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1769893708813,
|
||||
"tag": "0005_add_intakes_json",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+166
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "medassist-ng-backend",
|
||||
"version": "1.4.1",
|
||||
"version": "1.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "medassist-ng-backend",
|
||||
"version": "1.4.1",
|
||||
"version": "1.6.0",
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^10.0.1",
|
||||
"@fastify/cors": "^10.0.1",
|
||||
@@ -26,6 +26,7 @@
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.12",
|
||||
"@types/node": "^22.7.4",
|
||||
"@types/nodemailer": "^6.4.21",
|
||||
"@types/supertest": "^6.0.2",
|
||||
@@ -785,6 +786,169 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/biome": {
|
||||
"version": "2.3.12",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.12.tgz",
|
||||
"integrity": "sha512-AR7h4aSlAvXj7TAajW/V12BOw2EiS0AqZWV5dGozf4nlLoUF/ifvD0+YgKSskT0ylA6dY1A8AwgP8kZ6yaCQnA==",
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"bin": {
|
||||
"biome": "bin/biome"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/biome"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@biomejs/cli-darwin-arm64": "2.3.12",
|
||||
"@biomejs/cli-darwin-x64": "2.3.12",
|
||||
"@biomejs/cli-linux-arm64": "2.3.12",
|
||||
"@biomejs/cli-linux-arm64-musl": "2.3.12",
|
||||
"@biomejs/cli-linux-x64": "2.3.12",
|
||||
"@biomejs/cli-linux-x64-musl": "2.3.12",
|
||||
"@biomejs/cli-win32-arm64": "2.3.12",
|
||||
"@biomejs/cli-win32-x64": "2.3.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-darwin-arm64": {
|
||||
"version": "2.3.12",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.12.tgz",
|
||||
"integrity": "sha512-cO6fn+KiMBemva6EARDLQBxeyvLzgidaFRJi8G7OeRqz54kWK0E+uSjgFaiHlc3DZYoa0+1UFE8mDxozpc9ieg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-darwin-x64": {
|
||||
"version": "2.3.12",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.12.tgz",
|
||||
"integrity": "sha512-/fiF/qmudKwSdvmSrSe/gOTkW77mHHkH8Iy7YC2rmpLuk27kbaUOPa7kPiH5l+3lJzTUfU/t6x1OuIq/7SGtxg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-arm64": {
|
||||
"version": "2.3.12",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.12.tgz",
|
||||
"integrity": "sha512-nbOsuQROa3DLla5vvsTZg+T5WVPGi9/vYxETm9BOuLHBJN3oWQIg3MIkE2OfL18df1ZtNkqXkH6Yg9mdTPem7A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
||||
"version": "2.3.12",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.12.tgz",
|
||||
"integrity": "sha512-aqkeSf7IH+wkzFpKeDVPSXy9uDjxtLpYA6yzkYsY+tVjwFFirSuajHDI3ul8en90XNs1NA0n8kgBrjwRi5JeyA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-x64": {
|
||||
"version": "2.3.12",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.12.tgz",
|
||||
"integrity": "sha512-CQtqrJ+qEEI8tgRSTjjzk6wJAwfH3wQlkIGsM5dlecfRZaoT+XCms/mf7G4kWNexrke6mnkRzNy6w8ebV177ow==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-x64-musl": {
|
||||
"version": "2.3.12",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.12.tgz",
|
||||
"integrity": "sha512-kVGWtupRRsOjvw47YFkk5mLiAdpCPMWBo1jOwAzh+juDpUb2sWarIp+iq+CPL1Wt0LLZnYtP7hH5kD6fskcxmg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-win32-arm64": {
|
||||
"version": "2.3.12",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.12.tgz",
|
||||
"integrity": "sha512-Re4I7UnOoyE4kHMqpgtG6UvSBGBbbtvsOvBROgCCoH7EgANN6plSQhvo2W7OCITvTp7gD6oZOyZy72lUdXjqZg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-win32-x64": {
|
||||
"version": "2.3.12",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.12.tgz",
|
||||
"integrity": "sha512-qqGVWqNNek0KikwPZlOIoxtXgsNGsX+rgdEzgw82Re8nF02W+E2WokaQhpF5TdBh/D/RQ3TLppH+otp6ztN0lw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@drizzle-team/brocli": {
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "medassist-ng-backend",
|
||||
"version": "1.5.0",
|
||||
"version": "1.7.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -10,7 +10,11 @@
|
||||
"migrate": "tsx src/db/migrate.ts",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "npx biome check .",
|
||||
"lint:fix": "npx biome check --write .",
|
||||
"format": "npx biome format --write .",
|
||||
"check": "npx biome check . && tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^10.0.1",
|
||||
@@ -31,6 +35,7 @@
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.12",
|
||||
"@types/node": "^22.7.4",
|
||||
"@types/nodemailer": "^6.4.21",
|
||||
"@types/supertest": "^6.0.2",
|
||||
|
||||
+148
-128
@@ -1,10 +1,10 @@
|
||||
import { createClient, Client } from "@libsql/client";
|
||||
import { accessSync, constants, existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { type Client, createClient } from "@libsql/client";
|
||||
import dotenv from "dotenv";
|
||||
import { drizzle } from "drizzle-orm/libsql";
|
||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||
import { existsSync, mkdirSync, accessSync, constants, statSync, writeFileSync } from "fs";
|
||||
import { resolve, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
dotenv.config({ path: process.env.DOTENV_PATH || ".env" });
|
||||
|
||||
@@ -19,83 +19,103 @@ const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||
|
||||
/** Build the database URL from a path */
|
||||
export function buildDbUrl(dbPath: string): string {
|
||||
return `file:${dbPath}`;
|
||||
return `file:${dbPath}`;
|
||||
}
|
||||
|
||||
/** Get data directory and database path */
|
||||
export function getDbPaths(cwd: string = process.cwd()): { dataDir: string; dbPath: string; url: string } {
|
||||
const dataDir = resolve(cwd, "data");
|
||||
const dbPath = resolve(dataDir, "medassist-ng.db");
|
||||
const url = buildDbUrl(dbPath);
|
||||
return { dataDir, dbPath, url };
|
||||
const dataDir = resolve(cwd, "data");
|
||||
const dbPath = resolve(dataDir, "medassist-ng.db");
|
||||
const url = buildDbUrl(dbPath);
|
||||
return { dataDir, dbPath, url };
|
||||
}
|
||||
|
||||
/** Ensure data directory exists and is writable */
|
||||
export function ensureDataDirectory(dataDir: string): { success: boolean; error?: string } {
|
||||
try {
|
||||
if (!existsSync(dataDir)) {
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Check if directory is writable
|
||||
accessSync(dataDir, constants.W_OK);
|
||||
|
||||
// Try to create a test file to verify write access
|
||||
const testFile = resolve(dataDir, ".write-test");
|
||||
writeFileSync(testFile, "test");
|
||||
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
try {
|
||||
if (!existsSync(dataDir)) {
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Check if directory is writable
|
||||
accessSync(dataDir, constants.W_OK);
|
||||
|
||||
// Try to create a test file to verify write access
|
||||
const testFile = resolve(dataDir, ".write-test");
|
||||
writeFileSync(testFile, "test");
|
||||
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
/** Run drizzle-kit migrations on the database */
|
||||
export async function runDrizzleMigrations(database: ReturnType<typeof drizzle>): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
await migrate(database, { migrationsFolder });
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
export async function runDrizzleMigrations(
|
||||
database: ReturnType<typeof drizzle>
|
||||
): Promise<{ success: boolean; error?: string; warning?: string }> {
|
||||
try {
|
||||
await migrate(database, { migrationsFolder });
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
// If the error is "duplicate column", it means the schema is already up-to-date
|
||||
// This happens when ALTER migrations in client.ts have already added the columns
|
||||
// We consider this a success with a warning, not a failure
|
||||
if (err.message?.includes("duplicate column")) {
|
||||
return { success: true, warning: `Schema already up-to-date: ${err.message}` };
|
||||
}
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
/** Run ALTER TABLE migrations for backward compatibility with older databases */
|
||||
export async function runAlterMigrations(client: Client): Promise<{ success: boolean; errors: string[] }> {
|
||||
const errors: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
// These add new columns to existing tables (silently fail if column already exists)
|
||||
const alterMigrations = [
|
||||
// Added in v1.x - repeat reminders and nagging settings
|
||||
`ALTER TABLE user_settings ADD COLUMN skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE user_settings ADD COLUMN repeat_reminders_enabled integer NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE user_settings ADD COLUMN reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30`,
|
||||
`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`,
|
||||
// Added in v1.2.3 - dismiss missed doses without deducting stock
|
||||
`ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`,
|
||||
// Added in v1.3.x - stock calculation mode (automatic/manual)
|
||||
`ALTER TABLE user_settings ADD COLUMN stock_calculation_mode text NOT NULL DEFAULT 'automatic'`,
|
||||
// Added for stock correction - hidden offset that doesn't affect looseTablets
|
||||
`ALTER TABLE medications ADD COLUMN stock_adjustment integer NOT NULL DEFAULT 0`,
|
||||
// Added for stock correction - timestamp to ignore consumed doses before correction
|
||||
`ALTER TABLE medications ADD COLUMN last_stock_correction_at integer`,
|
||||
];
|
||||
// These add new columns to existing tables (silently fail if column already exists)
|
||||
const alterMigrations = [
|
||||
// Added in v1.x - repeat reminders and nagging settings
|
||||
`ALTER TABLE user_settings ADD COLUMN skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE user_settings ADD COLUMN repeat_reminders_enabled integer NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE user_settings ADD COLUMN reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30`,
|
||||
`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`,
|
||||
// Added in v1.2.3 - dismiss missed doses without deducting stock
|
||||
`ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`,
|
||||
// Added in v1.3.x - stock calculation mode (automatic/manual)
|
||||
`ALTER TABLE user_settings ADD COLUMN stock_calculation_mode text NOT NULL DEFAULT 'automatic'`,
|
||||
// Added for stock correction - hidden offset that doesn't affect looseTablets
|
||||
`ALTER TABLE medications ADD COLUMN stock_adjustment integer NOT NULL DEFAULT 0`,
|
||||
// Added for stock correction - timestamp to ignore consumed doses before correction
|
||||
`ALTER TABLE medications ADD COLUMN last_stock_correction_at integer`,
|
||||
// Added in v1.5.1 - dismiss past doses until date (robust against timestamp changes)
|
||||
`ALTER TABLE medications ADD COLUMN dismissed_until text`,
|
||||
// Added for more detailed reminder info display
|
||||
`ALTER TABLE user_settings ADD COLUMN last_reminder_med_name text`,
|
||||
`ALTER TABLE user_settings ADD COLUMN last_reminder_taken_by text`,
|
||||
// Added for package type support (blister vs bottle)
|
||||
`ALTER TABLE medications ADD COLUMN package_type text NOT NULL DEFAULT 'blister'`,
|
||||
`ALTER TABLE medications ADD COLUMN total_pills integer`,
|
||||
// Added for dose unit selection (mg, g, mcg, ml, IU, etc.)
|
||||
`ALTER TABLE medications ADD COLUMN dose_unit text DEFAULT 'mg'`,
|
||||
// Added for intake-level takenBy: unified intakes structure
|
||||
`ALTER TABLE medications ADD COLUMN intakes_json text NOT NULL DEFAULT '[]'`,
|
||||
];
|
||||
|
||||
for (const sql of alterMigrations) {
|
||||
try {
|
||||
await client.execute(sql);
|
||||
} catch (e: any) {
|
||||
// Silently ignore "duplicate column" errors - column already exists
|
||||
if (!e.message?.includes("duplicate column")) {
|
||||
errors.push(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const sql of alterMigrations) {
|
||||
try {
|
||||
await client.execute(sql);
|
||||
} catch (e: any) {
|
||||
// Silently ignore "duplicate column" errors - column already exists
|
||||
if (!e.message?.includes("duplicate column")) {
|
||||
errors.push(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create tables that might be missing (silently fail if already exists)
|
||||
const createTableMigrations = [
|
||||
// Added in v1.3.x - refill history tracking
|
||||
`CREATE TABLE IF NOT EXISTS refill_history (
|
||||
// Create tables that might be missing (silently fail if already exists)
|
||||
const createTableMigrations = [
|
||||
// Added in v1.3.x - refill history tracking
|
||||
`CREATE TABLE IF NOT EXISTS refill_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
medication_id INTEGER NOT NULL REFERENCES medications(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
@@ -103,41 +123,39 @@ 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'))
|
||||
)`,
|
||||
];
|
||||
];
|
||||
|
||||
for (const sql of createTableMigrations) {
|
||||
try {
|
||||
await client.execute(sql);
|
||||
} catch (e: any) {
|
||||
// Silently ignore "table already exists" errors
|
||||
if (!e.message?.includes("already exists")) {
|
||||
errors.push(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const sql of createTableMigrations) {
|
||||
try {
|
||||
await client.execute(sql);
|
||||
} catch (e: any) {
|
||||
// Silently ignore "table already exists" errors
|
||||
if (!e.message?.includes("already exists")) {
|
||||
errors.push(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: errors.length === 0, errors };
|
||||
return { success: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
/** Ensure default user exists for auth-disabled mode */
|
||||
export async function ensureDefaultUser(client: Client, authEnabled: boolean): Promise<boolean> {
|
||||
if (authEnabled) {
|
||||
return false; // No default user needed
|
||||
}
|
||||
if (authEnabled) {
|
||||
return false; // No default user needed
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await client.execute("SELECT id FROM users WHERE id = 1");
|
||||
if (result.rows.length === 0) {
|
||||
await client.execute(
|
||||
"INSERT INTO users (id, username, auth_provider) VALUES (1, 'default', 'local')"
|
||||
);
|
||||
return true; // Created
|
||||
}
|
||||
return false; // Already exists
|
||||
} catch (e: any) {
|
||||
console.error(`[DB] Error creating default user:`, e.message);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const result = await client.execute("SELECT id FROM users WHERE id = 1");
|
||||
if (result.rows.length === 0) {
|
||||
await client.execute("INSERT INTO users (id, username, auth_provider) VALUES (1, 'default', 'local')");
|
||||
return true; // Created
|
||||
}
|
||||
return false; // Already exists
|
||||
} catch (e: any) {
|
||||
console.error(`[DB] Error creating default user:`, e.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -154,56 +172,58 @@ console.log(`[DB] Database URL: ${url}`);
|
||||
// Ensure data directory exists and is writable
|
||||
const dirResult = ensureDataDirectory(dataDir);
|
||||
if (!dirResult.success) {
|
||||
console.error(`[DB] ERROR: Cannot access data directory: ${dirResult.error}`);
|
||||
console.error(`[DB] Please ensure the volume mount has correct permissions.`);
|
||||
console.error(`[DB] Try running on host: sudo chown -R 1000:1000 ${dataDir}`);
|
||||
process.exit(1);
|
||||
console.error(`[DB] ERROR: Cannot access data directory: ${dirResult.error}`);
|
||||
console.error(`[DB] Please ensure the volume mount has correct permissions.`);
|
||||
console.error(`[DB] Try running on host: sudo chown -R 1000:1000 ${dataDir}`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log(`[DB] Data directory is writable`);
|
||||
|
||||
// Log directory stats
|
||||
const stats = statSync(dataDir);
|
||||
console.log(`[DB] Directory permissions: ${stats.mode.toString(8)}`);
|
||||
console.log(`[DB] Directory UID: ${stats.uid}, GID: ${stats.gid}`);
|
||||
console.log(`[DB] Write test successful`);
|
||||
console.log(`[DB] Data directory is writable`);
|
||||
|
||||
// Log directory stats
|
||||
const stats = statSync(dataDir);
|
||||
console.log(`[DB] Directory permissions: ${stats.mode.toString(8)}`);
|
||||
console.log(`[DB] Directory UID: ${stats.uid}, GID: ${stats.gid}`);
|
||||
console.log(`[DB] Write test successful`);
|
||||
}
|
||||
|
||||
let client: Client;
|
||||
try {
|
||||
client = createClient({ url });
|
||||
console.log(`[DB] Database client created successfully`);
|
||||
client = createClient({ url });
|
||||
console.log(`[DB] Database client created successfully`);
|
||||
} catch (err: any) {
|
||||
console.error(`[DB] ERROR: Failed to create database client: ${err.message}`);
|
||||
console.error(`[DB] Database path: ${dbPath}`);
|
||||
process.exit(1);
|
||||
console.error(`[DB] ERROR: Failed to create database client: ${err.message}`);
|
||||
console.error(`[DB] Database path: ${dbPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
export const db = drizzle(client);
|
||||
|
||||
// Auto-run migrations (self-healing database)
|
||||
async function runMigrations() {
|
||||
// Run drizzle-kit generated migrations
|
||||
console.log(`[DB] Running drizzle migrations from: ${migrationsFolder}`);
|
||||
const migrateResult = await runDrizzleMigrations(db);
|
||||
if (!migrateResult.success) {
|
||||
console.error(`[DB] Migration error:`, migrateResult.error);
|
||||
} else {
|
||||
console.log(`[DB] Drizzle migrations completed`);
|
||||
}
|
||||
// Run drizzle-kit generated migrations
|
||||
console.log(`[DB] Running drizzle migrations from: ${migrationsFolder}`);
|
||||
const migrateResult = await runDrizzleMigrations(db);
|
||||
if (!migrateResult.success) {
|
||||
console.error(`[DB] Migration error:`, migrateResult.error);
|
||||
} else if (migrateResult.warning) {
|
||||
console.log(`[DB] Migration warning:`, migrateResult.warning);
|
||||
} else {
|
||||
console.log(`[DB] Drizzle migrations completed`);
|
||||
}
|
||||
|
||||
// Run ALTER TABLE migrations for backward compatibility
|
||||
const alterResult = await runAlterMigrations(client);
|
||||
if (alterResult.errors.length > 0) {
|
||||
alterResult.errors.forEach(err => console.error(`[DB] ALTER migration error:`, err));
|
||||
}
|
||||
console.log(`[DB] Tables verified/created`);
|
||||
// Run ALTER TABLE migrations for backward compatibility
|
||||
const alterResult = await runAlterMigrations(client);
|
||||
if (alterResult.errors.length > 0) {
|
||||
alterResult.errors.forEach((err) => console.error(`[DB] ALTER migration error:`, err));
|
||||
}
|
||||
console.log(`[DB] Tables verified/created`);
|
||||
|
||||
// If auth is disabled, ensure a default user exists (ID=1)
|
||||
const authEnabled = process.env.AUTH_ENABLED === "true";
|
||||
const created = await ensureDefaultUser(client, authEnabled);
|
||||
if (created) {
|
||||
console.log(`[DB] Created default user for auth-disabled mode`);
|
||||
}
|
||||
// If auth is disabled, ensure a default user exists (ID=1)
|
||||
const authEnabled = process.env.AUTH_ENABLED === "true";
|
||||
const created = await ensureDefaultUser(client, authEnabled);
|
||||
if (created) {
|
||||
console.log(`[DB] Created default user for auth-disabled mode`);
|
||||
}
|
||||
}
|
||||
|
||||
// Export promise so server can await it before starting
|
||||
|
||||
+44
-42
@@ -1,9 +1,9 @@
|
||||
import { createClient, Client } from "@libsql/client";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { type Client, createClient } from "@libsql/client";
|
||||
import dotenv from "dotenv";
|
||||
import { drizzle } from "drizzle-orm/libsql";
|
||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||
import dotenv from "dotenv";
|
||||
import { resolve, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
dotenv.config({ path: process.env.DOTENV_PATH || ".env" });
|
||||
|
||||
@@ -18,37 +18,39 @@ const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||
|
||||
/** Split SQL string into individual statements (for backwards compatibility with tests) */
|
||||
export function splitSQLStatements(sql: string): string[] {
|
||||
return sql.split(';').filter(s => s.trim().length > 0);
|
||||
return sql.split(";").filter((s) => s.trim().length > 0);
|
||||
}
|
||||
|
||||
/** Execute drizzle migrations on a database */
|
||||
export async function executeMigration(client: Client): Promise<{ success: boolean; executed: number; errors: string[] }> {
|
||||
const errors: string[] = [];
|
||||
const db = drizzle(client);
|
||||
export async function executeMigration(
|
||||
client: Client
|
||||
): Promise<{ success: boolean; executed: number; errors: string[] }> {
|
||||
const errors: string[] = [];
|
||||
const db = drizzle(client);
|
||||
|
||||
try {
|
||||
await migrate(db, { migrationsFolder });
|
||||
|
||||
// Count tables as a proxy for "executed" statements
|
||||
const tables = await client.execute(
|
||||
"SELECT COUNT(*) as count FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__drizzle%'"
|
||||
);
|
||||
const executed = Number(tables.rows[0].count) || 0;
|
||||
|
||||
return { success: true, executed, errors };
|
||||
} catch (err: any) {
|
||||
errors.push(err.message);
|
||||
return { success: false, executed: 0, errors };
|
||||
}
|
||||
try {
|
||||
await migrate(db, { migrationsFolder });
|
||||
|
||||
// Count tables as a proxy for "executed" statements
|
||||
const tables = await client.execute(
|
||||
"SELECT COUNT(*) as count FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__drizzle%'"
|
||||
);
|
||||
const executed = Number(tables.rows[0].count) || 0;
|
||||
|
||||
return { success: true, executed, errors };
|
||||
} catch (err: any) {
|
||||
errors.push(err.message);
|
||||
return { success: false, executed: 0, errors };
|
||||
}
|
||||
}
|
||||
|
||||
/** Get a preview of statement (first N characters) */
|
||||
export function getStatementPreview(stmt: string, maxLength: number = 50): string {
|
||||
const trimmed = stmt.trim();
|
||||
if (trimmed.length <= maxLength) {
|
||||
return trimmed;
|
||||
}
|
||||
return trimmed.substring(0, maxLength) + "...";
|
||||
const trimmed = stmt.trim();
|
||||
if (trimmed.length <= maxLength) {
|
||||
return trimmed;
|
||||
}
|
||||
return `${trimmed.substring(0, maxLength)}...`;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -58,25 +60,25 @@ export function getStatementPreview(stmt: string, maxLength: number = 50): strin
|
||||
const url = "file:./data/medassist-ng.db";
|
||||
|
||||
async function main() {
|
||||
console.log("Starting database setup...");
|
||||
console.log("Database URL:", url);
|
||||
console.log("Migrations folder:", migrationsFolder);
|
||||
|
||||
const client = createClient({ url });
|
||||
const db = drizzle(client);
|
||||
|
||||
console.log("Running drizzle migrations...");
|
||||
await migrate(db, { migrationsFolder });
|
||||
console.log("Starting database setup...");
|
||||
console.log("Database URL:", url);
|
||||
console.log("Migrations folder:", migrationsFolder);
|
||||
|
||||
console.log("Database setup complete!");
|
||||
process.exit(0);
|
||||
const client = createClient({ url });
|
||||
const db = drizzle(client);
|
||||
|
||||
console.log("Running drizzle migrations...");
|
||||
await migrate(db, { migrationsFolder });
|
||||
|
||||
console.log("Database setup complete!");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Only run main() if this file is executed directly (not imported)
|
||||
const isMainModule = import.meta.url === `file://${process.argv[1]}`;
|
||||
if (isMainModule) {
|
||||
main().catch((err) => {
|
||||
console.error("Migration failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
main().catch((err) => {
|
||||
console.error("Migration failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
* Each statement creates a table if it doesn't exist.
|
||||
*/
|
||||
export function getTableCreationSQL(): string[] {
|
||||
return [
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
return [
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
username text NOT NULL UNIQUE,
|
||||
password_hash text,
|
||||
@@ -21,7 +21,7 @@ export function getTableCreationSQL(): string[] {
|
||||
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now'))
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS medications (
|
||||
`CREATE TABLE IF NOT EXISTS medications (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL,
|
||||
name text NOT NULL,
|
||||
@@ -42,7 +42,7 @@ export function getTableCreationSQL(): string[] {
|
||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS user_settings (
|
||||
`CREATE TABLE IF NOT EXISTS user_settings (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL UNIQUE,
|
||||
email_enabled integer NOT NULL DEFAULT 0,
|
||||
@@ -71,7 +71,7 @@ export function getTableCreationSQL(): string[] {
|
||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
`CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL,
|
||||
token_id text NOT NULL UNIQUE,
|
||||
@@ -81,7 +81,7 @@ export function getTableCreationSQL(): string[] {
|
||||
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS share_tokens (
|
||||
`CREATE TABLE IF NOT EXISTS share_tokens (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL,
|
||||
token text NOT NULL UNIQUE,
|
||||
@@ -91,7 +91,7 @@ export function getTableCreationSQL(): string[] {
|
||||
expires_at integer,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS dose_tracking (
|
||||
`CREATE TABLE IF NOT EXISTS dose_tracking (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL,
|
||||
dose_id text NOT NULL,
|
||||
@@ -100,7 +100,7 @@ export function getTableCreationSQL(): string[] {
|
||||
dismissed integer NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS refill_history (
|
||||
`CREATE TABLE IF NOT EXISTS refill_history (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
medication_id integer NOT NULL,
|
||||
user_id integer NOT NULL,
|
||||
@@ -110,5 +110,5 @@ export function getTableCreationSQL(): string[] {
|
||||
FOREIGN KEY (medication_id) REFERENCES medications(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
];
|
||||
];
|
||||
}
|
||||
|
||||
+114
-91
@@ -1,134 +1,157 @@
|
||||
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
|
||||
// =============================================================================
|
||||
// Users - Simple auth, no roles (every user is equal)
|
||||
// =============================================================================
|
||||
export const users = sqliteTable("users", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
username: text("username", { length: 100 }).notNull().unique(),
|
||||
passwordHash: text("password_hash", { length: 255 }),
|
||||
avatarUrl: text("avatar_url", { length: 255 }),
|
||||
authProvider: text("auth_provider", { length: 50 }).notNull().default("local"),
|
||||
oidcSubject: text("oidc_subject", { length: 255 }), // OIDC provider's unique user ID (sub claim)
|
||||
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
|
||||
lastLoginAt: integer("last_login_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`),
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
username: text("username", { length: 100 }).notNull().unique(),
|
||||
passwordHash: text("password_hash", { length: 255 }),
|
||||
avatarUrl: text("avatar_url", { length: 255 }),
|
||||
authProvider: text("auth_provider", { length: 50 }).notNull().default("local"),
|
||||
oidcSubject: text("oidc_subject", { length: 255 }), // OIDC provider's unique user ID (sub claim)
|
||||
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
|
||||
lastLoginAt: integer("last_login_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`),
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Medications - Per user
|
||||
// =============================================================================
|
||||
export const medications = sqliteTable("medications", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name", { length: 100 }).notNull(),
|
||||
genericName: text("generic_name", { length: 100 }),
|
||||
takenByJson: text("taken_by_json").notNull().default("[]"), // JSON array of person names
|
||||
packCount: integer("pack_count").notNull().default(1),
|
||||
blistersPerPack: integer("blisters_per_pack").notNull().default(1),
|
||||
pillsPerBlister: integer("pills_per_blister").notNull().default(1),
|
||||
looseTablets: integer("loose_tablets").notNull().default(0), // TRUE loose pills (user-entered)
|
||||
stockAdjustment: integer("stock_adjustment").notNull().default(0), // Hidden offset from stock corrections
|
||||
lastStockCorrectionAt: integer("last_stock_correction_at", { mode: "timestamp" }), // When stock was last corrected - consumed doses before this don't count
|
||||
pillWeightMg: integer("pill_weight_mg"),
|
||||
usageJson: text("usage_json").notNull().default("[]"),
|
||||
everyJson: text("every_json").notNull().default("[]"),
|
||||
startJson: text("start_json").notNull().default("[]"),
|
||||
imageUrl: text("image_url"),
|
||||
expiryDate: text("expiry_date"),
|
||||
notes: text("notes"),
|
||||
intakeRemindersEnabled: integer("intake_reminders_enabled", { mode: "boolean" }).notNull().default(false),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: integer("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name", { length: 100 }).notNull(),
|
||||
genericName: text("generic_name", { length: 100 }),
|
||||
takenByJson: text("taken_by_json").notNull().default("[]"), // JSON array of person names
|
||||
packageType: text("package_type", { length: 20 }).notNull().default("blister"), // 'blister' or 'bottle'
|
||||
packCount: integer("pack_count").notNull().default(1),
|
||||
blistersPerPack: integer("blisters_per_pack").notNull().default(1),
|
||||
pillsPerBlister: integer("pills_per_blister").notNull().default(1),
|
||||
totalPills: integer("total_pills"), // For bottle type: total capacity of the container
|
||||
looseTablets: integer("loose_tablets").notNull().default(0), // For blister: extra loose pills; for bottle: current stock
|
||||
stockAdjustment: integer("stock_adjustment").notNull().default(0), // Hidden offset from stock corrections
|
||||
lastStockCorrectionAt: integer("last_stock_correction_at", { mode: "timestamp" }), // When stock was last corrected - consumed doses before this don't count
|
||||
pillWeightMg: integer("pill_weight_mg"),
|
||||
doseUnit: text("dose_unit", { length: 20 }).default("mg"), // Unit for the dose (mg, g, mcg, ml, IU, etc.)
|
||||
usageJson: text("usage_json").notNull().default("[]"), // DEPRECATED: Use intakesJson instead
|
||||
everyJson: text("every_json").notNull().default("[]"), // DEPRECATED: Use intakesJson instead
|
||||
startJson: text("start_json").notNull().default("[]"), // DEPRECATED: Use intakesJson instead
|
||||
// New unified intakes structure: [{usage, every, start, takenBy, intakeRemindersEnabled}]
|
||||
intakesJson: text("intakes_json").notNull().default("[]"),
|
||||
imageUrl: text("image_url"),
|
||||
expiryDate: text("expiry_date"),
|
||||
notes: text("notes"),
|
||||
intakeRemindersEnabled: integer("intake_reminders_enabled", { mode: "boolean" }).notNull().default(false),
|
||||
dismissedUntil: text("dismissed_until"), // ISO date string (e.g. "2026-01-23") - all past doses until this date are dismissed
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// User Settings - Per user (email, push, thresholds, language)
|
||||
// =============================================================================
|
||||
export const userSettings = sqliteTable("user_settings", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: integer("user_id").notNull().unique().references(() => users.id, { onDelete: "cascade" }),
|
||||
// Email notifications
|
||||
emailEnabled: integer("email_enabled", { mode: "boolean" }).notNull().default(false),
|
||||
notificationEmail: text("notification_email"),
|
||||
emailStockReminders: integer("email_stock_reminders", { mode: "boolean" }).notNull().default(true),
|
||||
emailIntakeReminders: integer("email_intake_reminders", { mode: "boolean" }).notNull().default(true),
|
||||
// Push notifications (shoutrrr/ntfy)
|
||||
shoutrrrEnabled: integer("shoutrrr_enabled", { mode: "boolean" }).notNull().default(false),
|
||||
shoutrrrUrl: text("shoutrrr_url"),
|
||||
shoutrrrStockReminders: integer("shoutrrr_stock_reminders", { mode: "boolean" }).notNull().default(true),
|
||||
shoutrrrIntakeReminders: integer("shoutrrr_intake_reminders", { mode: "boolean" }).notNull().default(true),
|
||||
// Reminder settings
|
||||
reminderDaysBefore: integer("reminder_days_before").notNull().default(7),
|
||||
repeatDailyReminders: integer("repeat_daily_reminders", { mode: "boolean" }).notNull().default(false),
|
||||
skipRemindersForTakenDoses: integer("skip_reminders_for_taken_doses", { mode: "boolean" }).notNull().default(false),
|
||||
repeatRemindersEnabled: integer("repeat_reminders_enabled", { mode: "boolean" }).notNull().default(false),
|
||||
reminderRepeatIntervalMinutes: integer("reminder_repeat_interval_minutes").notNull().default(30),
|
||||
maxNaggingReminders: integer("max_nagging_reminders").notNull().default(5),
|
||||
// Stock thresholds (days)
|
||||
lowStockDays: integer("low_stock_days").notNull().default(30),
|
||||
normalStockDays: integer("normal_stock_days").notNull().default(90),
|
||||
highStockDays: integer("high_stock_days").notNull().default(180),
|
||||
expiryWarningDays: integer("expiry_warning_days").notNull().default(90),
|
||||
// UI preferences
|
||||
language: text("language", { length: 10 }).notNull().default("en"),
|
||||
// Stock calculation mode: "automatic" (schedule-based) or "manual" (only marked doses)
|
||||
stockCalculationMode: text("stock_calculation_mode", { length: 20 }).notNull().default("automatic"),
|
||||
// Last notification tracking
|
||||
lastAutoEmailSent: text("last_auto_email_sent"),
|
||||
lastNotificationType: text("last_notification_type"),
|
||||
lastNotificationChannel: text("last_notification_channel"),
|
||||
// Timestamps
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: integer("user_id")
|
||||
.notNull()
|
||||
.unique()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
// Email notifications
|
||||
emailEnabled: integer("email_enabled", { mode: "boolean" }).notNull().default(false),
|
||||
notificationEmail: text("notification_email"),
|
||||
emailStockReminders: integer("email_stock_reminders", { mode: "boolean" }).notNull().default(true),
|
||||
emailIntakeReminders: integer("email_intake_reminders", { mode: "boolean" }).notNull().default(true),
|
||||
// Push notifications (shoutrrr/ntfy)
|
||||
shoutrrrEnabled: integer("shoutrrr_enabled", { mode: "boolean" }).notNull().default(false),
|
||||
shoutrrrUrl: text("shoutrrr_url"),
|
||||
shoutrrrStockReminders: integer("shoutrrr_stock_reminders", { mode: "boolean" }).notNull().default(true),
|
||||
shoutrrrIntakeReminders: integer("shoutrrr_intake_reminders", { mode: "boolean" }).notNull().default(true),
|
||||
// Reminder settings
|
||||
reminderDaysBefore: integer("reminder_days_before").notNull().default(7),
|
||||
repeatDailyReminders: integer("repeat_daily_reminders", { mode: "boolean" }).notNull().default(false),
|
||||
skipRemindersForTakenDoses: integer("skip_reminders_for_taken_doses", { mode: "boolean" }).notNull().default(false),
|
||||
repeatRemindersEnabled: integer("repeat_reminders_enabled", { mode: "boolean" }).notNull().default(false),
|
||||
reminderRepeatIntervalMinutes: integer("reminder_repeat_interval_minutes").notNull().default(30),
|
||||
maxNaggingReminders: integer("max_nagging_reminders").notNull().default(5),
|
||||
// Stock thresholds (days)
|
||||
lowStockDays: integer("low_stock_days").notNull().default(30),
|
||||
normalStockDays: integer("normal_stock_days").notNull().default(90),
|
||||
highStockDays: integer("high_stock_days").notNull().default(180),
|
||||
expiryWarningDays: integer("expiry_warning_days").notNull().default(90),
|
||||
// UI preferences
|
||||
language: text("language", { length: 10 }).notNull().default("en"),
|
||||
// Stock calculation mode: "automatic" (schedule-based) or "manual" (only marked doses)
|
||||
stockCalculationMode: text("stock_calculation_mode", { length: 20 }).notNull().default("automatic"),
|
||||
// Last notification tracking
|
||||
lastAutoEmailSent: text("last_auto_email_sent"),
|
||||
lastNotificationType: text("last_notification_type"),
|
||||
lastNotificationChannel: text("last_notification_channel"),
|
||||
lastReminderMedName: text("last_reminder_med_name"),
|
||||
lastReminderTakenBy: text("last_reminder_taken_by"),
|
||||
// Timestamps
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Refresh Tokens - For JWT rotation
|
||||
// =============================================================================
|
||||
export const refreshTokens = sqliteTable("refresh_tokens", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
tokenId: text("token_id", { length: 255 }).notNull().unique(),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
|
||||
rotatedAt: integer("rotated_at", { mode: "timestamp" }),
|
||||
revoked: integer("revoked", { mode: "boolean" }).notNull().default(false),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: integer("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
tokenId: text("token_id", { length: 255 }).notNull().unique(),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
|
||||
rotatedAt: integer("rotated_at", { mode: "timestamp" }),
|
||||
revoked: integer("revoked", { mode: "boolean" }).notNull().default(false),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Share Tokens - For public schedule sharing by takenBy person
|
||||
// =============================================================================
|
||||
export const shareTokens = sqliteTable("share_tokens", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
token: text("token", { length: 64 }).notNull().unique(),
|
||||
takenBy: text("taken_by", { length: 100 }).notNull(),
|
||||
scheduleDays: integer("schedule_days").notNull().default(30),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp" }), // NULL = never expires
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: integer("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
token: text("token", { length: 64 }).notNull().unique(),
|
||||
takenBy: text("taken_by", { length: 100 }).notNull(),
|
||||
scheduleDays: integer("schedule_days").notNull().default(30),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp" }), // NULL = never expires
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Dose Tracking - Tracks when doses are marked as taken
|
||||
// =============================================================================
|
||||
export const doseTracking = sqliteTable("dose_tracking", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
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
|
||||
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // true = missed dose acknowledged without taking
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: integer("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
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
|
||||
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // true = missed dose acknowledged without taking
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Refill History - Tracks when medication stock was refilled
|
||||
// =============================================================================
|
||||
export const refillHistory = sqliteTable("refill_history", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
medicationId: integer("medication_id").notNull().references(() => medications.id, { onDelete: "cascade" }),
|
||||
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
packsAdded: integer("packs_added").notNull().default(0),
|
||||
loosePillsAdded: integer("loose_pills_added").notNull().default(0),
|
||||
refillDate: integer("refill_date", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
medicationId: integer("medication_id")
|
||||
.notNull()
|
||||
.references(() => medications.id, { onDelete: "cascade" }),
|
||||
userId: integer("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
packsAdded: integer("packs_added").notNull().default(0),
|
||||
loosePillsAdded: integer("loose_pills_added").notNull().default(0),
|
||||
refillDate: integer("refill_date", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
|
||||
});
|
||||
|
||||
+248
-175
@@ -1,193 +1,266 @@
|
||||
// Backend translations for notifications
|
||||
export type Language = "en" | "de";
|
||||
|
||||
/**
|
||||
* Map timezone to region code (ISO 3166-1 alpha-2).
|
||||
* This allows combining app language with regional formatting.
|
||||
*/
|
||||
const TIMEZONE_TO_REGION: Record<string, string> = {
|
||||
// Europe
|
||||
"Europe/Berlin": "DE",
|
||||
"Europe/Vienna": "AT",
|
||||
"Europe/Zurich": "CH",
|
||||
"Europe/London": "GB",
|
||||
"Europe/Dublin": "IE",
|
||||
"Europe/Paris": "FR",
|
||||
"Europe/Madrid": "ES",
|
||||
"Europe/Rome": "IT",
|
||||
"Europe/Amsterdam": "NL",
|
||||
"Europe/Brussels": "BE",
|
||||
"Europe/Warsaw": "PL",
|
||||
"Europe/Prague": "CZ",
|
||||
"Europe/Stockholm": "SE",
|
||||
"Europe/Oslo": "NO",
|
||||
"Europe/Copenhagen": "DK",
|
||||
"Europe/Helsinki": "FI",
|
||||
"Europe/Athens": "GR",
|
||||
"Europe/Lisbon": "PT",
|
||||
"Europe/Moscow": "RU",
|
||||
"Europe/Kiev": "UA",
|
||||
"Europe/Kyiv": "UA",
|
||||
"Europe/Budapest": "HU",
|
||||
"Europe/Bucharest": "RO",
|
||||
// Americas
|
||||
"America/New_York": "US",
|
||||
"America/Chicago": "US",
|
||||
"America/Denver": "US",
|
||||
"America/Los_Angeles": "US",
|
||||
"America/Phoenix": "US",
|
||||
"America/Toronto": "CA",
|
||||
"America/Vancouver": "CA",
|
||||
"America/Mexico_City": "MX",
|
||||
"America/Sao_Paulo": "BR",
|
||||
"America/Buenos_Aires": "AR",
|
||||
// Asia/Pacific
|
||||
"Asia/Tokyo": "JP",
|
||||
"Asia/Shanghai": "CN",
|
||||
"Asia/Hong_Kong": "HK",
|
||||
"Asia/Singapore": "SG",
|
||||
"Asia/Seoul": "KR",
|
||||
"Asia/Dubai": "AE",
|
||||
"Asia/Kolkata": "IN",
|
||||
"Australia/Sydney": "AU",
|
||||
"Australia/Melbourne": "AU",
|
||||
"Pacific/Auckland": "NZ",
|
||||
};
|
||||
|
||||
/**
|
||||
* Get region code from TZ environment variable.
|
||||
*/
|
||||
function getRegionFromTimezone(): string | undefined {
|
||||
const tz = process.env.TZ;
|
||||
if (!tz) return undefined;
|
||||
return TIMEZONE_TO_REGION[tz];
|
||||
}
|
||||
|
||||
type TranslationKeys = {
|
||||
// Stock reminder email
|
||||
stockReminder: {
|
||||
subject: string;
|
||||
title: string;
|
||||
description: string;
|
||||
alertSingle: string;
|
||||
alertMultiple: string;
|
||||
tableHeaders: {
|
||||
medication: string;
|
||||
pills: string;
|
||||
days: string;
|
||||
runsOut: string;
|
||||
};
|
||||
footer: string;
|
||||
repeatDailyNote: string;
|
||||
};
|
||||
// Intake reminder email
|
||||
intakeReminder: {
|
||||
subject: string;
|
||||
title: string;
|
||||
description: string;
|
||||
alertSingle: string;
|
||||
alertMultiple: string;
|
||||
tableHeaders: {
|
||||
medication: string;
|
||||
dosage: string;
|
||||
time: string;
|
||||
};
|
||||
pills: string;
|
||||
takenBy: string;
|
||||
footer: string;
|
||||
};
|
||||
// Push notifications
|
||||
push: {
|
||||
stockTitle: string;
|
||||
stockTitleMultiple: string;
|
||||
intakeTitle: string;
|
||||
pillsLeft: string;
|
||||
daysLeft: string;
|
||||
pillsAt: string;
|
||||
repeatDailyNote: string;
|
||||
empty: string;
|
||||
low: string;
|
||||
reorderNow: string;
|
||||
emptySection: string;
|
||||
lowSection: string;
|
||||
};
|
||||
// Common
|
||||
common: {
|
||||
pill: string;
|
||||
pills: string;
|
||||
day: string;
|
||||
days: string;
|
||||
soon: string;
|
||||
};
|
||||
// Stock reminder email
|
||||
stockReminder: {
|
||||
subject: string;
|
||||
title: string;
|
||||
description: string;
|
||||
alertSingle: string;
|
||||
alertMultiple: string;
|
||||
tableHeaders: {
|
||||
medication: string;
|
||||
pills: string;
|
||||
days: string;
|
||||
runsOut: string;
|
||||
};
|
||||
footer: string;
|
||||
repeatDailyNote: string;
|
||||
};
|
||||
// Intake reminder email
|
||||
intakeReminder: {
|
||||
subject: string;
|
||||
title: string;
|
||||
description: string;
|
||||
alertSingle: string;
|
||||
alertMultiple: string;
|
||||
tableHeaders: {
|
||||
medication: string;
|
||||
dosage: string;
|
||||
time: string;
|
||||
};
|
||||
pills: string;
|
||||
takenBy: string;
|
||||
footer: string;
|
||||
};
|
||||
// Push notifications
|
||||
push: {
|
||||
stockTitle: string;
|
||||
stockTitleMultiple: string;
|
||||
intakeTitle: string;
|
||||
pillsLeft: string;
|
||||
daysLeft: string;
|
||||
pillsAt: string;
|
||||
repeatDailyNote: string;
|
||||
empty: string;
|
||||
low: string;
|
||||
reorderNow: string;
|
||||
emptySection: string;
|
||||
lowSection: string;
|
||||
};
|
||||
// Common
|
||||
common: {
|
||||
pill: string;
|
||||
pills: string;
|
||||
day: string;
|
||||
days: string;
|
||||
soon: string;
|
||||
};
|
||||
};
|
||||
|
||||
const translations: Record<Language, TranslationKeys> = {
|
||||
en: {
|
||||
stockReminder: {
|
||||
subject: "MedAssist-ng Auto-Reminder: {count} Medication{s} Running Low",
|
||||
title: "⚠️ MedAssist-ng - Automatic Reorder Reminder",
|
||||
description: "The following medications are running low and need to be reordered:",
|
||||
alertSingle: "⚠️ 1 medication running low!",
|
||||
alertMultiple: "⚠️ {count} medications running low!",
|
||||
tableHeaders: {
|
||||
medication: "Medication",
|
||||
pills: "Pills",
|
||||
days: "Days",
|
||||
runsOut: "Runs Out",
|
||||
},
|
||||
footer: "🤖 Automatic reminder from MedAssist-ng",
|
||||
repeatDailyNote: "You are receiving this daily reminder because 'Repeat Daily' is enabled in settings.",
|
||||
},
|
||||
intakeReminder: {
|
||||
subject: "MedAssist-ng: Medication Reminder - {medications}",
|
||||
title: "💊 MedAssist-ng - Intake Reminder",
|
||||
description: "Time to take your medication in {minutes} minutes:",
|
||||
alertSingle: "💊 1 medication scheduled",
|
||||
alertMultiple: "💊 {count} medications scheduled",
|
||||
tableHeaders: {
|
||||
medication: "Medication",
|
||||
dosage: "Dosage",
|
||||
time: "Time",
|
||||
},
|
||||
pills: "pills",
|
||||
takenBy: "for {name}",
|
||||
footer: "🤖 Automatic reminder from MedAssist-ng",
|
||||
},
|
||||
push: {
|
||||
stockTitle: "MedAssist-ng: 1 Medication Running Low",
|
||||
stockTitleMultiple: "MedAssist-ng: {count} Medications Running Low",
|
||||
intakeTitle: "💊 Medication Reminder in {minutes} min",
|
||||
pillsLeft: "{count} pills",
|
||||
daysLeft: "{count} days left",
|
||||
pillsAt: "{count} pills at {time}",
|
||||
repeatDailyNote: "(Daily reminder enabled)",
|
||||
empty: "Empty",
|
||||
low: "Low",
|
||||
reorderNow: "Reorder Now!",
|
||||
emptySection: "EMPTY (reorder immediately)",
|
||||
lowSection: "RUNNING LOW (reorder soon)",
|
||||
},
|
||||
common: {
|
||||
pill: "pill",
|
||||
pills: "pills",
|
||||
day: "day",
|
||||
days: "days",
|
||||
soon: "soon",
|
||||
},
|
||||
},
|
||||
de: {
|
||||
stockReminder: {
|
||||
subject: "MedAssist-ng Auto-Erinnerung: {count} Medikament{e} wird knapp",
|
||||
title: "⚠️ MedAssist-ng - Automatische Nachbestell-Erinnerung",
|
||||
description: "Die folgenden Medikamente gehen zur Neige und sollten nachbestellt werden:",
|
||||
alertSingle: "⚠️ 1 Medikament wird knapp!",
|
||||
alertMultiple: "⚠️ {count} Medikamente werden knapp!",
|
||||
tableHeaders: {
|
||||
medication: "Medikament",
|
||||
pills: "Tabletten",
|
||||
days: "Tage",
|
||||
runsOut: "Aufgebraucht",
|
||||
},
|
||||
footer: "🤖 Automatische Erinnerung von MedAssist-ng",
|
||||
repeatDailyNote: "Sie erhalten diese tägliche Erinnerung, weil 'Täglich wiederholen' in den Einstellungen aktiviert ist.",
|
||||
},
|
||||
intakeReminder: {
|
||||
subject: "MedAssist-ng: Einnahme-Erinnerung - {medications}",
|
||||
title: "💊 MedAssist-ng - Einnahme-Erinnerung",
|
||||
description: "Zeit für Ihre Medikamente in {minutes} Minuten:",
|
||||
alertSingle: "💊 1 Medikament geplant",
|
||||
alertMultiple: "💊 {count} Medikamente geplant",
|
||||
tableHeaders: {
|
||||
medication: "Medikament",
|
||||
dosage: "Dosis",
|
||||
time: "Uhrzeit",
|
||||
},
|
||||
pills: "Tabletten",
|
||||
takenBy: "für {name}",
|
||||
footer: "🤖 Automatische Erinnerung von MedAssist-ng",
|
||||
},
|
||||
push: {
|
||||
stockTitle: "MedAssist-ng: 1 Medikament wird knapp",
|
||||
stockTitleMultiple: "MedAssist-ng: {count} Medikamente werden knapp",
|
||||
intakeTitle: "💊 Einnahme-Erinnerung in {minutes} Min.",
|
||||
pillsLeft: "{count} Tabletten",
|
||||
daysLeft: "{count} Tage übrig",
|
||||
pillsAt: "{count} Tabletten um {time}",
|
||||
repeatDailyNote: "(Tägliche Erinnerung aktiviert)",
|
||||
empty: "Leer",
|
||||
low: "Knapp",
|
||||
reorderNow: "Jetzt nachbestellen!",
|
||||
emptySection: "LEER (sofort nachbestellen)",
|
||||
lowSection: "WIRD KNAPP (bald nachbestellen)",
|
||||
},
|
||||
common: {
|
||||
pill: "Tablette",
|
||||
pills: "Tabletten",
|
||||
day: "Tag",
|
||||
days: "Tage",
|
||||
soon: "bald",
|
||||
},
|
||||
},
|
||||
en: {
|
||||
stockReminder: {
|
||||
subject: "MedAssist-ng Auto-Reminder: {count} Medication{s} Running Low",
|
||||
title: "⚠️ MedAssist-ng - Automatic Reorder Reminder",
|
||||
description: "The following medications are running low and need to be reordered:",
|
||||
alertSingle: "⚠️ 1 medication running low!",
|
||||
alertMultiple: "⚠️ {count} medications running low!",
|
||||
tableHeaders: {
|
||||
medication: "Medication",
|
||||
pills: "Pills",
|
||||
days: "Days",
|
||||
runsOut: "Runs Out",
|
||||
},
|
||||
footer: "🤖 Automatic reminder from MedAssist-ng",
|
||||
repeatDailyNote: "You are receiving this daily reminder because 'Repeat Daily' is enabled in settings.",
|
||||
},
|
||||
intakeReminder: {
|
||||
subject: "MedAssist-ng: Medication Reminder - {medications}",
|
||||
title: "💊 MedAssist-ng - Intake Reminder",
|
||||
description: "Time to take your medication in {minutes} minutes:",
|
||||
alertSingle: "💊 1 medication scheduled",
|
||||
alertMultiple: "💊 {count} medications scheduled",
|
||||
tableHeaders: {
|
||||
medication: "Medication",
|
||||
dosage: "Dosage",
|
||||
time: "Time",
|
||||
},
|
||||
pills: "pills",
|
||||
takenBy: "for {name}",
|
||||
footer: "🤖 Automatic reminder from MedAssist-ng",
|
||||
},
|
||||
push: {
|
||||
stockTitle: "MedAssist-ng: 1 Medication Running Low",
|
||||
stockTitleMultiple: "MedAssist-ng: {count} Medications Running Low",
|
||||
intakeTitle: "💊 Medication Reminder in {minutes} min",
|
||||
pillsLeft: "{count} pills",
|
||||
daysLeft: "{count} days left",
|
||||
pillsAt: "{count} pills at {time}",
|
||||
repeatDailyNote: "(Daily reminder enabled)",
|
||||
empty: "Empty",
|
||||
low: "Low",
|
||||
reorderNow: "Reorder Now!",
|
||||
emptySection: "EMPTY (reorder immediately)",
|
||||
lowSection: "RUNNING LOW (reorder soon)",
|
||||
},
|
||||
common: {
|
||||
pill: "pill",
|
||||
pills: "pills",
|
||||
day: "day",
|
||||
days: "days",
|
||||
soon: "soon",
|
||||
},
|
||||
},
|
||||
de: {
|
||||
stockReminder: {
|
||||
subject: "MedAssist-ng Auto-Erinnerung: {count} Medikament{e} wird knapp",
|
||||
title: "⚠️ MedAssist-ng - Automatische Nachbestell-Erinnerung",
|
||||
description: "Die folgenden Medikamente gehen zur Neige und sollten nachbestellt werden:",
|
||||
alertSingle: "⚠️ 1 Medikament wird knapp!",
|
||||
alertMultiple: "⚠️ {count} Medikamente werden knapp!",
|
||||
tableHeaders: {
|
||||
medication: "Medikament",
|
||||
pills: "Tabletten",
|
||||
days: "Tage",
|
||||
runsOut: "Aufgebraucht",
|
||||
},
|
||||
footer: "🤖 Automatische Erinnerung von MedAssist-ng",
|
||||
repeatDailyNote:
|
||||
"Sie erhalten diese tägliche Erinnerung, weil 'Täglich wiederholen' in den Einstellungen aktiviert ist.",
|
||||
},
|
||||
intakeReminder: {
|
||||
subject: "MedAssist-ng: Einnahme-Erinnerung - {medications}",
|
||||
title: "💊 MedAssist-ng - Einnahme-Erinnerung",
|
||||
description: "Zeit für Ihre Medikamente in {minutes} Minuten:",
|
||||
alertSingle: "💊 1 Medikament geplant",
|
||||
alertMultiple: "💊 {count} Medikamente geplant",
|
||||
tableHeaders: {
|
||||
medication: "Medikament",
|
||||
dosage: "Dosis",
|
||||
time: "Uhrzeit",
|
||||
},
|
||||
pills: "Tabletten",
|
||||
takenBy: "für {name}",
|
||||
footer: "🤖 Automatische Erinnerung von MedAssist-ng",
|
||||
},
|
||||
push: {
|
||||
stockTitle: "MedAssist-ng: 1 Medikament wird knapp",
|
||||
stockTitleMultiple: "MedAssist-ng: {count} Medikamente werden knapp",
|
||||
intakeTitle: "💊 Einnahme-Erinnerung in {minutes} Min.",
|
||||
pillsLeft: "{count} Tabletten",
|
||||
daysLeft: "{count} Tage übrig",
|
||||
pillsAt: "{count} Tabletten um {time}",
|
||||
repeatDailyNote: "(Tägliche Erinnerung aktiviert)",
|
||||
empty: "Leer",
|
||||
low: "Knapp",
|
||||
reorderNow: "Jetzt nachbestellen!",
|
||||
emptySection: "LEER (sofort nachbestellen)",
|
||||
lowSection: "WIRD KNAPP (bald nachbestellen)",
|
||||
},
|
||||
common: {
|
||||
pill: "Tablette",
|
||||
pills: "Tabletten",
|
||||
day: "Tag",
|
||||
days: "Tage",
|
||||
soon: "bald",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function getTranslations(language: Language): TranslationKeys {
|
||||
return translations[language] || translations.en;
|
||||
return translations[language] || translations.en;
|
||||
}
|
||||
|
||||
// Helper function to replace placeholders in strings
|
||||
export function t(template: string, params: Record<string, string | number> = {}): string {
|
||||
let result = template;
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
result = result.replace(new RegExp(`\\{${key}\\}`, "g"), String(value));
|
||||
}
|
||||
return result;
|
||||
let result = template;
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
result = result.replace(new RegExp(`\\{${key}\\}`, "g"), String(value));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Get date locale for toLocaleDateString
|
||||
/**
|
||||
* Get locale for formatting based on language and timezone region.
|
||||
* Combines language (en/de) with region from timezone (DE/US/etc.)
|
||||
* Example: lang=en + TZ=Europe/Berlin → en-DE (English text, German format = 24h time)
|
||||
*/
|
||||
export function getDateLocale(language: Language): string {
|
||||
switch (language) {
|
||||
case "de":
|
||||
return "de-DE";
|
||||
case "en":
|
||||
default:
|
||||
return "en-US";
|
||||
}
|
||||
const region = getRegionFromTimezone();
|
||||
|
||||
if (region) {
|
||||
return `${language}-${region}`;
|
||||
}
|
||||
|
||||
// Fallback: use language default
|
||||
switch (language) {
|
||||
case "de":
|
||||
return "de-DE";
|
||||
default:
|
||||
return "en-US";
|
||||
}
|
||||
}
|
||||
|
||||
+127
-127
@@ -1,124 +1,124 @@
|
||||
import Fastify, { FastifyInstance } from "fastify";
|
||||
import helmet from "@fastify/helmet";
|
||||
import cors from "@fastify/cors";
|
||||
import rateLimit from "@fastify/rate-limit";
|
||||
import sensible from "@fastify/sensible";
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import cookie from "@fastify/cookie";
|
||||
import cors from "@fastify/cors";
|
||||
import helmet from "@fastify/helmet";
|
||||
import jwt from "@fastify/jwt";
|
||||
import fastifyMultipart from "@fastify/multipart";
|
||||
import rateLimit from "@fastify/rate-limit";
|
||||
import sensible from "@fastify/sensible";
|
||||
import fastifyStatic from "@fastify/static";
|
||||
import { resolve } from "path";
|
||||
import { existsSync } from "fs";
|
||||
import { env } from "./plugins/env.js";
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { migrationsReady } from "./db/client.js";
|
||||
import { healthRoutes } from "./routes/health.js";
|
||||
import { env } from "./plugins/env.js";
|
||||
import { authRoutes } from "./routes/auth.js";
|
||||
import { oidcRoutes } from "./routes/oidc.js";
|
||||
import { medicationRoutes } from "./routes/medications.js";
|
||||
import { settingsRoutes } from "./routes/settings.js";
|
||||
import { plannerRoutes } from "./routes/planner.js";
|
||||
import { shareRoutes } from "./routes/share.js";
|
||||
import { doseRoutes } from "./routes/doses.js";
|
||||
import { exportRoutes } from "./routes/export.js";
|
||||
import { healthRoutes } from "./routes/health.js";
|
||||
import { medicationRoutes } from "./routes/medications.js";
|
||||
import { oidcRoutes } from "./routes/oidc.js";
|
||||
import { plannerRoutes } from "./routes/planner.js";
|
||||
import { refillRoutes } from "./routes/refills.js";
|
||||
import { startReminderScheduler } from "./services/reminder-scheduler.js";
|
||||
import { settingsRoutes } from "./routes/settings.js";
|
||||
import { shareRoutes } from "./routes/share.js";
|
||||
import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js";
|
||||
import { startReminderScheduler } from "./services/reminder-scheduler.js";
|
||||
|
||||
// Re-export utilities from server-config for external use
|
||||
export {
|
||||
parseCorsOrigins,
|
||||
buildBaseCookieOptions,
|
||||
buildRefreshCookieOptions,
|
||||
buildAppConfig,
|
||||
ensureImagesDirectory,
|
||||
getJwtConfig,
|
||||
buildAppConfig,
|
||||
buildBaseCookieOptions,
|
||||
buildRefreshCookieOptions,
|
||||
ensureImagesDirectory,
|
||||
getJwtConfig,
|
||||
parseCorsOrigins,
|
||||
} from "./utils/server-config.js";
|
||||
|
||||
import {
|
||||
parseCorsOrigins,
|
||||
buildBaseCookieOptions,
|
||||
buildRefreshCookieOptions,
|
||||
buildAppConfig,
|
||||
ensureImagesDirectory,
|
||||
getJwtConfig,
|
||||
buildAppConfig,
|
||||
buildBaseCookieOptions,
|
||||
buildRefreshCookieOptions,
|
||||
ensureImagesDirectory,
|
||||
getJwtConfig,
|
||||
parseCorsOrigins,
|
||||
} from "./utils/server-config.js";
|
||||
|
||||
/** Create and configure Fastify app (without starting) */
|
||||
export async function createApp(options?: {
|
||||
logLevel?: string;
|
||||
corsOrigins?: string[];
|
||||
authEnabled?: boolean;
|
||||
jwtSecret?: string;
|
||||
refreshSecret?: string;
|
||||
cookieSecret?: string;
|
||||
accessTtlMinutes?: number;
|
||||
refreshTtlDays?: number;
|
||||
isProduction?: boolean;
|
||||
imagesDir?: string;
|
||||
logLevel?: string;
|
||||
corsOrigins?: string[];
|
||||
authEnabled?: boolean;
|
||||
jwtSecret?: string;
|
||||
refreshSecret?: string;
|
||||
cookieSecret?: string;
|
||||
accessTtlMinutes?: number;
|
||||
refreshTtlDays?: number;
|
||||
isProduction?: boolean;
|
||||
imagesDir?: string;
|
||||
}): Promise<FastifyInstance> {
|
||||
const opts = {
|
||||
logLevel: options?.logLevel ?? "info",
|
||||
corsOrigins: options?.corsOrigins ?? ["http://localhost:5173"],
|
||||
authEnabled: options?.authEnabled ?? false,
|
||||
jwtSecret: options?.jwtSecret,
|
||||
refreshSecret: options?.refreshSecret,
|
||||
cookieSecret: options?.cookieSecret ?? "dev-cookie-secret",
|
||||
accessTtlMinutes: options?.accessTtlMinutes ?? 15,
|
||||
refreshTtlDays: options?.refreshTtlDays ?? 7,
|
||||
isProduction: options?.isProduction ?? false,
|
||||
imagesDir: options?.imagesDir ?? resolve(process.cwd(), "data/images"),
|
||||
};
|
||||
const opts = {
|
||||
logLevel: options?.logLevel ?? "info",
|
||||
corsOrigins: options?.corsOrigins ?? ["http://localhost:5173"],
|
||||
authEnabled: options?.authEnabled ?? false,
|
||||
jwtSecret: options?.jwtSecret,
|
||||
refreshSecret: options?.refreshSecret,
|
||||
cookieSecret: options?.cookieSecret ?? "dev-cookie-secret",
|
||||
accessTtlMinutes: options?.accessTtlMinutes ?? 15,
|
||||
refreshTtlDays: options?.refreshTtlDays ?? 7,
|
||||
isProduction: options?.isProduction ?? false,
|
||||
imagesDir: options?.imagesDir ?? resolve(process.cwd(), "data/images"),
|
||||
};
|
||||
|
||||
const app = Fastify({
|
||||
logger: { level: opts.logLevel },
|
||||
});
|
||||
const app = Fastify({
|
||||
logger: { level: opts.logLevel },
|
||||
});
|
||||
|
||||
// Build config
|
||||
const appConfig = buildAppConfig({
|
||||
jwtSecret: opts.jwtSecret,
|
||||
refreshSecret: opts.refreshSecret,
|
||||
accessTtlMinutes: opts.accessTtlMinutes,
|
||||
refreshTtlDays: opts.refreshTtlDays,
|
||||
isProduction: opts.isProduction,
|
||||
});
|
||||
// Build config
|
||||
const appConfig = buildAppConfig({
|
||||
jwtSecret: opts.jwtSecret,
|
||||
refreshSecret: opts.refreshSecret,
|
||||
accessTtlMinutes: opts.accessTtlMinutes,
|
||||
refreshTtlDays: opts.refreshTtlDays,
|
||||
isProduction: opts.isProduction,
|
||||
});
|
||||
|
||||
app.decorate("config", appConfig);
|
||||
app.decorate("config", appConfig);
|
||||
|
||||
// Register plugins
|
||||
await app.register(sensible);
|
||||
await app.register(helmet);
|
||||
await app.register(cors, { origin: opts.corsOrigins, credentials: true });
|
||||
await app.register(rateLimit, { max: 100, timeWindow: "1 minute" });
|
||||
await app.register(cookie, { secret: opts.cookieSecret });
|
||||
// Register plugins
|
||||
await app.register(sensible);
|
||||
await app.register(helmet);
|
||||
await app.register(cors, { origin: opts.corsOrigins, credentials: true });
|
||||
await app.register(rateLimit, { max: 300, timeWindow: "1 minute" });
|
||||
await app.register(cookie, { secret: opts.cookieSecret });
|
||||
|
||||
// JWT plugin
|
||||
const jwtConfig = getJwtConfig(opts.authEnabled, opts.jwtSecret);
|
||||
await app.register(jwt, jwtConfig);
|
||||
// JWT plugin
|
||||
const jwtConfig = getJwtConfig(opts.authEnabled, opts.jwtSecret);
|
||||
await app.register(jwt, jwtConfig);
|
||||
|
||||
await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } });
|
||||
|
||||
// Only register static if directory exists
|
||||
if (existsSync(opts.imagesDir)) {
|
||||
await app.register(fastifyStatic, {
|
||||
root: opts.imagesDir,
|
||||
prefix: "/images/",
|
||||
decorateReply: false,
|
||||
});
|
||||
}
|
||||
await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } });
|
||||
|
||||
// Register routes
|
||||
await app.register(healthRoutes);
|
||||
await app.register(authRoutes);
|
||||
await app.register(oidcRoutes);
|
||||
await app.register(medicationRoutes);
|
||||
await app.register(settingsRoutes);
|
||||
await app.register(plannerRoutes);
|
||||
await app.register(shareRoutes);
|
||||
await app.register(doseRoutes);
|
||||
await app.register(exportRoutes);
|
||||
await app.register(refillRoutes);
|
||||
// Only register static if directory exists
|
||||
if (existsSync(opts.imagesDir)) {
|
||||
await app.register(fastifyStatic, {
|
||||
root: opts.imagesDir,
|
||||
prefix: "/images/",
|
||||
decorateReply: false,
|
||||
});
|
||||
}
|
||||
|
||||
return app;
|
||||
// Register routes
|
||||
await app.register(healthRoutes);
|
||||
await app.register(authRoutes);
|
||||
await app.register(oidcRoutes);
|
||||
await app.register(medicationRoutes);
|
||||
await app.register(settingsRoutes);
|
||||
await app.register(plannerRoutes);
|
||||
await app.register(shareRoutes);
|
||||
await app.register(doseRoutes);
|
||||
await app.register(exportRoutes);
|
||||
await app.register(refillRoutes);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -133,36 +133,36 @@ console.log("[DB] Migrations complete, starting server...");
|
||||
const imagesDir = ensureImagesDirectory();
|
||||
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
level: env.LOG_LEVEL,
|
||||
},
|
||||
logger: {
|
||||
level: env.LOG_LEVEL,
|
||||
},
|
||||
});
|
||||
|
||||
const origins = parseCorsOrigins(env.CORS_ORIGINS);
|
||||
|
||||
// Auth token TTLs (hardcoded - no need for user configuration)
|
||||
const accessTtlMinutes = env.ACCESS_TOKEN_TTL_MINUTES; // Access token TTL
|
||||
const refreshTtlDays = env.REFRESH_TOKEN_TTL_DAYS; // Refresh token TTL
|
||||
const accessTtlMinutes = env.ACCESS_TOKEN_TTL_MINUTES; // Access token TTL
|
||||
const refreshTtlDays = env.REFRESH_TOKEN_TTL_DAYS; // Refresh token TTL
|
||||
|
||||
const baseCookieOptions = buildBaseCookieOptions(accessTtlMinutes, env.NODE_ENV === "production");
|
||||
const refreshCookieOptions = buildRefreshCookieOptions(baseCookieOptions, refreshTtlDays);
|
||||
|
||||
// Config decorator - only include secrets if auth is enabled
|
||||
app.decorate("config", {
|
||||
accessSecret: env.JWT_SECRET ?? "",
|
||||
refreshSecret: env.REFRESH_SECRET ?? "",
|
||||
accessTtl: accessTtlMinutes,
|
||||
refreshTtl: refreshTtlDays,
|
||||
cookieOptions: baseCookieOptions,
|
||||
refreshCookieOptions,
|
||||
accessSecret: env.JWT_SECRET ?? "",
|
||||
refreshSecret: env.REFRESH_SECRET ?? "",
|
||||
accessTtl: accessTtlMinutes,
|
||||
refreshTtl: refreshTtlDays,
|
||||
cookieOptions: baseCookieOptions,
|
||||
refreshCookieOptions,
|
||||
});
|
||||
|
||||
await app.register(sensible);
|
||||
await app.register(helmet);
|
||||
await app.register(cors, { origin: origins, credentials: true });
|
||||
await app.register(rateLimit, {
|
||||
max: 100,
|
||||
timeWindow: "1 minute",
|
||||
max: 100,
|
||||
timeWindow: "1 minute",
|
||||
});
|
||||
await app.register(cookie, { secret: env.COOKIE_SECRET ?? "dev-cookie-secret" });
|
||||
|
||||
@@ -172,9 +172,9 @@ await app.register(jwt, jwtConfig);
|
||||
|
||||
await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } }); // 10MB limit
|
||||
await app.register(fastifyStatic, {
|
||||
root: imagesDir,
|
||||
prefix: "/images/",
|
||||
decorateReply: false,
|
||||
root: imagesDir,
|
||||
prefix: "/images/",
|
||||
decorateReply: false,
|
||||
});
|
||||
|
||||
await app.register(healthRoutes);
|
||||
@@ -189,25 +189,25 @@ await app.register(exportRoutes);
|
||||
await app.register(refillRoutes);
|
||||
|
||||
const start = async () => {
|
||||
try {
|
||||
await app.listen({ port: env.PORT, host: "0.0.0.0" });
|
||||
app.log.info(`Server running on ${env.PORT}`);
|
||||
|
||||
// Start the automatic reminder scheduler
|
||||
startReminderScheduler({
|
||||
info: (msg) => app.log.info(msg),
|
||||
error: (msg) => app.log.error(msg),
|
||||
});
|
||||
|
||||
// Start the intake reminder scheduler (checks every minute)
|
||||
startIntakeReminderScheduler({
|
||||
info: (msg) => app.log.info(msg),
|
||||
error: (msg) => app.log.error(msg),
|
||||
});
|
||||
} catch (err) {
|
||||
app.log.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
await app.listen({ port: env.PORT, host: "0.0.0.0" });
|
||||
app.log.info(`Server running on ${env.PORT}`);
|
||||
|
||||
// Start the automatic reminder scheduler
|
||||
startReminderScheduler({
|
||||
info: (msg) => app.log.info(msg),
|
||||
error: (msg) => app.log.error(msg),
|
||||
});
|
||||
|
||||
// Start the intake reminder scheduler (checks every minute)
|
||||
startIntakeReminderScheduler({
|
||||
info: (msg) => app.log.info(msg),
|
||||
error: (msg) => app.log.error(msg),
|
||||
});
|
||||
} catch (err) {
|
||||
app.log.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
start();
|
||||
|
||||
+100
-100
@@ -1,8 +1,8 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
|
||||
import { env } from "./env.js";
|
||||
import { count, eq, sql } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { db } from "../db/client.js";
|
||||
import { users } from "../db/schema.js";
|
||||
import { sql, count, eq } from "drizzle-orm";
|
||||
import { env } from "./env.js";
|
||||
|
||||
// =============================================================================
|
||||
// Anonymous User - Used when AUTH_ENABLED=false
|
||||
@@ -17,67 +17,67 @@ let anonymousUserVerified = false;
|
||||
* Uses a fixed ID (999999999) that will never collide with auto-increment IDs.
|
||||
*/
|
||||
export async function getAnonymousUserId(): Promise<number> {
|
||||
// Return cached if already verified
|
||||
if (anonymousUserVerified) {
|
||||
return ANONYMOUS_USER_ID;
|
||||
}
|
||||
// Return cached if already verified
|
||||
if (anonymousUserVerified) {
|
||||
return ANONYMOUS_USER_ID;
|
||||
}
|
||||
|
||||
// Check if anonymous user exists
|
||||
const [existing] = await db.select().from(users).where(eq(users.id, ANONYMOUS_USER_ID));
|
||||
|
||||
if (existing) {
|
||||
anonymousUserVerified = true;
|
||||
return ANONYMOUS_USER_ID;
|
||||
}
|
||||
// Check if anonymous user exists
|
||||
const [existing] = await db.select().from(users).where(eq(users.id, ANONYMOUS_USER_ID));
|
||||
|
||||
// Create anonymous user with fixed ID (SQLite allows explicit ID)
|
||||
await db.run(sql`
|
||||
if (existing) {
|
||||
anonymousUserVerified = true;
|
||||
return ANONYMOUS_USER_ID;
|
||||
}
|
||||
|
||||
// Create anonymous user with fixed ID (SQLite allows explicit ID)
|
||||
await db.run(sql`
|
||||
INSERT INTO users (id, username, password_hash, auth_provider, is_active, created_at, updated_at)
|
||||
VALUES (${ANONYMOUS_USER_ID}, ${ANONYMOUS_USERNAME}, NULL, 'anonymous', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
`);
|
||||
|
||||
anonymousUserVerified = true;
|
||||
console.log(`Created anonymous user with fixed ID ${ANONYMOUS_USER_ID} for no-auth mode`);
|
||||
|
||||
return ANONYMOUS_USER_ID;
|
||||
anonymousUserVerified = true;
|
||||
console.log(`Created anonymous user with fixed ID ${ANONYMOUS_USER_ID} for no-auth mode`);
|
||||
|
||||
return ANONYMOUS_USER_ID;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Auth State - Computed at runtime
|
||||
// =============================================================================
|
||||
export interface AuthState {
|
||||
authEnabled: boolean;
|
||||
registrationEnabled: boolean;
|
||||
localAuthEnabled: boolean;
|
||||
oidcEnabled: boolean;
|
||||
oidcProviderName: string;
|
||||
hasUsers: boolean;
|
||||
needsSetup: boolean;
|
||||
authEnabled: boolean;
|
||||
registrationEnabled: boolean;
|
||||
localAuthEnabled: boolean;
|
||||
oidcEnabled: boolean;
|
||||
oidcProviderName: string;
|
||||
hasUsers: boolean;
|
||||
needsSetup: boolean;
|
||||
}
|
||||
|
||||
export async function getAuthState(): Promise<AuthState> {
|
||||
// Count only real users (not the anonymous user with fixed ID)
|
||||
const [result] = await db.select({ count: count() }).from(users).where(sql`${users.id} != ${ANONYMOUS_USER_ID}`);
|
||||
const hasUsers = result.count > 0;
|
||||
|
||||
return {
|
||||
authEnabled: env.AUTH_ENABLED,
|
||||
// Registration: enabled via ENV OR no users exist (first-time setup)
|
||||
registrationEnabled: env.REGISTRATION_ENABLED || !hasUsers,
|
||||
localAuthEnabled: env.AUTH_ENABLED, // Password auth available when auth is enabled
|
||||
oidcEnabled: env.OIDC_ENABLED,
|
||||
oidcProviderName: env.OIDC_PROVIDER_NAME,
|
||||
hasUsers,
|
||||
needsSetup: env.AUTH_ENABLED && !hasUsers,
|
||||
};
|
||||
// Count only real users (not the anonymous user with fixed ID)
|
||||
const [result] = await db.select({ count: count() }).from(users).where(sql`${users.id} != ${ANONYMOUS_USER_ID}`);
|
||||
const hasUsers = result.count > 0;
|
||||
|
||||
return {
|
||||
authEnabled: env.AUTH_ENABLED,
|
||||
// Registration: enabled via ENV OR no users exist (first-time setup)
|
||||
registrationEnabled: env.REGISTRATION_ENABLED || !hasUsers,
|
||||
localAuthEnabled: env.AUTH_ENABLED, // Password auth available when auth is enabled
|
||||
oidcEnabled: env.OIDC_ENABLED,
|
||||
oidcProviderName: env.OIDC_PROVIDER_NAME,
|
||||
hasUsers,
|
||||
needsSetup: env.AUTH_ENABLED && !hasUsers,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Request User Type (no roles - all users are equal)
|
||||
// =============================================================================
|
||||
export interface RequestUser {
|
||||
id: number;
|
||||
username: string;
|
||||
id: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -87,78 +87,78 @@ export interface RequestUser {
|
||||
/**
|
||||
* Optional auth - verifies JWT if present, but doesn't require it
|
||||
*/
|
||||
export async function optionalAuth(request: FastifyRequest, reply: FastifyReply) {
|
||||
if (!env.AUTH_ENABLED) {
|
||||
return;
|
||||
}
|
||||
export async function optionalAuth(request: FastifyRequest, _reply: FastifyReply) {
|
||||
if (!env.AUTH_ENABLED) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = request.cookies.access_token;
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
const token = request.cookies.access_token;
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = await request.jwtVerify<{ sub: number; username: string }>();
|
||||
const [user] = await db.select().from(users).where(sql`${users.id} = ${decoded.sub}`);
|
||||
if (user && user.isActive) {
|
||||
request.user = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Invalid token, continue as anonymous
|
||||
}
|
||||
try {
|
||||
const decoded = await request.jwtVerify<{ sub: number; username: string }>();
|
||||
const [user] = await db.select().from(users).where(sql`${users.id} = ${decoded.sub}`);
|
||||
if (user?.isActive) {
|
||||
request.user = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Invalid token, continue as anonymous
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Required auth - requires valid JWT when auth is enabled
|
||||
*/
|
||||
export async function requireAuth(request: FastifyRequest, reply: FastifyReply) {
|
||||
if (!env.AUTH_ENABLED) {
|
||||
return;
|
||||
}
|
||||
if (!env.AUTH_ENABLED) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = request.cookies.access_token;
|
||||
if (!token) {
|
||||
reply.status(401).send({ error: "Authentication required", code: "AUTH_REQUIRED" });
|
||||
throw new Error("AUTH_REQUIRED");
|
||||
}
|
||||
const token = request.cookies.access_token;
|
||||
if (!token) {
|
||||
reply.status(401).send({ error: "Authentication required", code: "AUTH_REQUIRED" });
|
||||
throw new Error("AUTH_REQUIRED");
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = await request.jwtVerify<{ sub: number; username: string }>();
|
||||
const [user] = await db.select().from(users).where(sql`${users.id} = ${decoded.sub}`);
|
||||
|
||||
if (!user) {
|
||||
reply.status(401).send({ error: "User not found", code: "USER_NOT_FOUND" });
|
||||
throw new Error("USER_NOT_FOUND");
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
reply.status(401).send({ error: "Account disabled", code: "ACCOUNT_DISABLED" });
|
||||
throw new Error("ACCOUNT_DISABLED");
|
||||
}
|
||||
try {
|
||||
const decoded = await request.jwtVerify<{ sub: number; username: string }>();
|
||||
const [user] = await db.select().from(users).where(sql`${users.id} = ${decoded.sub}`);
|
||||
|
||||
request.user = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
};
|
||||
} catch (err: any) {
|
||||
// Re-throw our own errors
|
||||
if (err?.message === "AUTH_REQUIRED" || err?.message === "USER_NOT_FOUND" || err?.message === "ACCOUNT_DISABLED") {
|
||||
throw err;
|
||||
}
|
||||
// JWT verification failed
|
||||
reply.status(401).send({ error: "Invalid or expired token", code: "INVALID_TOKEN" });
|
||||
throw new Error("INVALID_TOKEN");
|
||||
}
|
||||
if (!user) {
|
||||
reply.status(401).send({ error: "User not found", code: "USER_NOT_FOUND" });
|
||||
throw new Error("USER_NOT_FOUND");
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
reply.status(401).send({ error: "Account disabled", code: "ACCOUNT_DISABLED" });
|
||||
throw new Error("ACCOUNT_DISABLED");
|
||||
}
|
||||
|
||||
request.user = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
};
|
||||
} catch (err: any) {
|
||||
// Re-throw our own errors
|
||||
if (err?.message === "AUTH_REQUIRED" || err?.message === "USER_NOT_FOUND" || err?.message === "ACCOUNT_DISABLED") {
|
||||
throw err;
|
||||
}
|
||||
// JWT verification failed
|
||||
reply.status(401).send({ error: "Invalid or expired token", code: "INVALID_TOKEN" });
|
||||
throw new Error("INVALID_TOKEN");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth state endpoint plugin
|
||||
*/
|
||||
export async function authPlugin(app: FastifyInstance) {
|
||||
app.get("/auth/state", async () => {
|
||||
return getAuthState();
|
||||
});
|
||||
app.get("/auth/state", async () => {
|
||||
return getAuthState();
|
||||
});
|
||||
}
|
||||
|
||||
+102
-82
@@ -1,45 +1,65 @@
|
||||
import { z } from "zod";
|
||||
import dotenv from "dotenv";
|
||||
import { z } from "zod";
|
||||
|
||||
dotenv.config({ path: process.env.DOTENV_PATH || ".env" });
|
||||
|
||||
const EnvSchema = z.object({
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
|
||||
PORT: z.string().transform((v) => parseInt(v, 10)).default("3000"),
|
||||
CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"),
|
||||
LOG_LEVEL: z.string().default("info"),
|
||||
|
||||
// ==========================================================================
|
||||
// Auth Configuration
|
||||
// ==========================================================================
|
||||
// Master switch: Enable/disable authentication (default: disabled for easy setup)
|
||||
AUTH_ENABLED: z.string().transform((v) => v === "true").default("false"),
|
||||
// Allow new user registrations (auto-enabled if no users exist)
|
||||
REGISTRATION_ENABLED: z.string().transform((v) => v === "true").default("false"),
|
||||
// Disable local auth when using SSO only
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
|
||||
PORT: z
|
||||
.string()
|
||||
.transform((v) => parseInt(v, 10))
|
||||
.default("3000"),
|
||||
CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"),
|
||||
LOG_LEVEL: z.string().default("info"),
|
||||
|
||||
|
||||
// JWT Secrets - only required when AUTH_ENABLED=true
|
||||
JWT_SECRET: z.string().min(10).optional(),
|
||||
REFRESH_SECRET: z.string().min(10).optional(),
|
||||
COOKIE_SECRET: z.string().min(10).optional(),
|
||||
|
||||
// Token TTL settings
|
||||
ACCESS_TOKEN_TTL_MINUTES: z.string().transform((v) => parseInt(v, 10)).default("15"),
|
||||
REFRESH_TOKEN_TTL_DAYS: z.string().transform((v) => parseInt(v, 10)).default("7"),
|
||||
// ==========================================================================
|
||||
// Auth Configuration
|
||||
// ==========================================================================
|
||||
// Master switch: Enable/disable authentication (default: disabled for easy setup)
|
||||
AUTH_ENABLED: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.default("false"),
|
||||
// Allow new user registrations (auto-enabled if no users exist)
|
||||
REGISTRATION_ENABLED: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.default("false"),
|
||||
// Disable local auth when using SSO only
|
||||
|
||||
// ==========================================================================
|
||||
// OIDC SSO Configuration (Pocket ID, Authelia, etc.)
|
||||
// ==========================================================================
|
||||
OIDC_ENABLED: z.string().transform((v) => v === "true").default("false"),
|
||||
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(),
|
||||
OIDC_REDIRECT_URI: z.string().url().optional(), // e.g., https://medassist.example.com/api/auth/oidc/callback
|
||||
OIDC_SCOPES: z.string().default("openid profile email"),
|
||||
OIDC_AUTO_CREATE_USERS: z.string().transform((v) => v === "true").default("true"),
|
||||
OIDC_USERNAME_CLAIM: z.string().default("preferred_username"), // or 'email', 'sub'
|
||||
OIDC_PROVIDER_NAME: z.string().default("SSO"), // Display name for UI button
|
||||
// JWT Secrets - only required when AUTH_ENABLED=true
|
||||
JWT_SECRET: z.string().min(10).optional(),
|
||||
REFRESH_SECRET: z.string().min(10).optional(),
|
||||
COOKIE_SECRET: z.string().min(10).optional(),
|
||||
|
||||
// Token TTL settings
|
||||
ACCESS_TOKEN_TTL_MINUTES: z
|
||||
.string()
|
||||
.transform((v) => parseInt(v, 10))
|
||||
.default("15"),
|
||||
REFRESH_TOKEN_TTL_DAYS: z
|
||||
.string()
|
||||
.transform((v) => parseInt(v, 10))
|
||||
.default("7"),
|
||||
|
||||
// ==========================================================================
|
||||
// OIDC SSO Configuration (Pocket ID, Authelia, etc.)
|
||||
// ==========================================================================
|
||||
OIDC_ENABLED: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.default("false"),
|
||||
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(),
|
||||
OIDC_REDIRECT_URI: z.string().url().optional(), // e.g., https://medassist.example.com/api/auth/oidc/callback
|
||||
OIDC_SCOPES: z.string().default("openid profile email"),
|
||||
OIDC_AUTO_CREATE_USERS: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.default("true"),
|
||||
OIDC_USERNAME_CLAIM: z.string().default("preferred_username"), // or 'email', 'sub'
|
||||
OIDC_PROVIDER_NAME: z.string().default("SSO"), // Display name for UI button
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof EnvSchema>;
|
||||
@@ -47,62 +67,62 @@ export type Env = z.infer<typeof EnvSchema>;
|
||||
// Parse and validate
|
||||
let parsed: z.infer<typeof EnvSchema>;
|
||||
try {
|
||||
parsed = EnvSchema.parse(process.env);
|
||||
parsed = EnvSchema.parse(process.env);
|
||||
} catch (err) {
|
||||
console.error("=".repeat(60));
|
||||
console.error("ENVIRONMENT CONFIGURATION ERROR");
|
||||
console.error("=".repeat(60));
|
||||
console.error(err);
|
||||
console.error("\nPlease check your .env file or environment variables.");
|
||||
console.error("=".repeat(60));
|
||||
process.exit(1);
|
||||
console.error("=".repeat(60));
|
||||
console.error("ENVIRONMENT CONFIGURATION ERROR");
|
||||
console.error("=".repeat(60));
|
||||
console.error(err);
|
||||
console.error("\nPlease check your .env file or environment variables.");
|
||||
console.error("=".repeat(60));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Validate that secrets are provided when auth is enabled
|
||||
if (parsed.AUTH_ENABLED) {
|
||||
const missing: string[] = [];
|
||||
if (!parsed.JWT_SECRET) missing.push("JWT_SECRET");
|
||||
if (!parsed.REFRESH_SECRET) missing.push("REFRESH_SECRET");
|
||||
if (!parsed.COOKIE_SECRET) missing.push("COOKIE_SECRET");
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.error("=".repeat(60));
|
||||
console.error("AUTHENTICATION CONFIGURATION ERROR");
|
||||
console.error("=".repeat(60));
|
||||
console.error(`AUTH_ENABLED=true but missing required secrets: ${missing.join(", ")}`);
|
||||
console.error("");
|
||||
console.error("To fix this, either:");
|
||||
console.error(" 1. Set these environment variables with secure random values:");
|
||||
console.error(" Generate with: openssl rand -hex 32");
|
||||
console.error("");
|
||||
console.error(" 2. Or disable authentication by removing AUTH_ENABLED=true");
|
||||
console.error("=".repeat(60));
|
||||
process.exit(1);
|
||||
}
|
||||
const missing: string[] = [];
|
||||
if (!parsed.JWT_SECRET) missing.push("JWT_SECRET");
|
||||
if (!parsed.REFRESH_SECRET) missing.push("REFRESH_SECRET");
|
||||
if (!parsed.COOKIE_SECRET) missing.push("COOKIE_SECRET");
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.error("=".repeat(60));
|
||||
console.error("AUTHENTICATION CONFIGURATION ERROR");
|
||||
console.error("=".repeat(60));
|
||||
console.error(`AUTH_ENABLED=true but missing required secrets: ${missing.join(", ")}`);
|
||||
console.error("");
|
||||
console.error("To fix this, either:");
|
||||
console.error(" 1. Set these environment variables with secure random values:");
|
||||
console.error(" Generate with: openssl rand -hex 32");
|
||||
console.error("");
|
||||
console.error(" 2. Or disable authentication by removing AUTH_ENABLED=true");
|
||||
console.error("=".repeat(60));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate OIDC configuration when enabled
|
||||
if (parsed.OIDC_ENABLED) {
|
||||
const missing: string[] = [];
|
||||
if (!parsed.OIDC_ISSUER_URL) missing.push("OIDC_ISSUER_URL");
|
||||
if (!parsed.OIDC_CLIENT_ID) missing.push("OIDC_CLIENT_ID");
|
||||
if (!parsed.OIDC_CLIENT_SECRET) missing.push("OIDC_CLIENT_SECRET");
|
||||
if (!parsed.OIDC_REDIRECT_URI) missing.push("OIDC_REDIRECT_URI");
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.error("=".repeat(60));
|
||||
console.error("OIDC CONFIGURATION ERROR");
|
||||
console.error("=".repeat(60));
|
||||
console.error(`OIDC_ENABLED=true but missing required settings: ${missing.join(", ")}`);
|
||||
console.error("");
|
||||
console.error("Required OIDC settings:");
|
||||
console.error(" OIDC_ISSUER_URL=https://your-oidc-provider.com");
|
||||
console.error(" OIDC_CLIENT_ID=your-client-id");
|
||||
console.error(" OIDC_CLIENT_SECRET=your-client-secret");
|
||||
console.error(" OIDC_REDIRECT_URI=https://your-app.com/api/auth/oidc/callback");
|
||||
console.error("=".repeat(60));
|
||||
process.exit(1);
|
||||
}
|
||||
const missing: string[] = [];
|
||||
if (!parsed.OIDC_ISSUER_URL) missing.push("OIDC_ISSUER_URL");
|
||||
if (!parsed.OIDC_CLIENT_ID) missing.push("OIDC_CLIENT_ID");
|
||||
if (!parsed.OIDC_CLIENT_SECRET) missing.push("OIDC_CLIENT_SECRET");
|
||||
if (!parsed.OIDC_REDIRECT_URI) missing.push("OIDC_REDIRECT_URI");
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.error("=".repeat(60));
|
||||
console.error("OIDC CONFIGURATION ERROR");
|
||||
console.error("=".repeat(60));
|
||||
console.error(`OIDC_ENABLED=true but missing required settings: ${missing.join(", ")}`);
|
||||
console.error("");
|
||||
console.error("Required OIDC settings:");
|
||||
console.error(" OIDC_ISSUER_URL=https://your-oidc-provider.com");
|
||||
console.error(" OIDC_CLIENT_ID=your-client-id");
|
||||
console.error(" OIDC_CLIENT_SECRET=your-client-secret");
|
||||
console.error(" OIDC_REDIRECT_URI=https://your-app.com/api/auth/oidc/callback");
|
||||
console.error("=".repeat(60));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export const env = parsed;
|
||||
|
||||
+471
-406
@@ -1,11 +1,10 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import argon2 from "argon2";
|
||||
import { randomBytes } from "crypto";
|
||||
import { db } from "../db/client.js";
|
||||
import { users, refreshTokens } from "../db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { env } from "../plugins/env.js";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
import { refreshTokens, users } from "../db/schema.js";
|
||||
import { getAuthState, requireAuth } from "../plugins/auth.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
|
||||
@@ -13,11 +12,11 @@ import type { AuthUser } from "../types/fastify.js";
|
||||
// Argon2id Configuration - State of the Art Password Hashing
|
||||
// =============================================================================
|
||||
const ARGON2_OPTIONS: argon2.Options = {
|
||||
type: argon2.argon2id, // Argon2id - best for password hashing
|
||||
memoryCost: 65536, // 64 MB memory
|
||||
timeCost: 3, // 3 iterations
|
||||
parallelism: 4, // 4 parallel threads
|
||||
hashLength: 32, // 256-bit hash
|
||||
type: argon2.argon2id, // Argon2id - best for password hashing
|
||||
memoryCost: 65536, // 64 MB memory
|
||||
timeCost: 3, // 3 iterations
|
||||
parallelism: 4, // 4 parallel threads
|
||||
hashLength: 32, // 256-bit hash
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
@@ -29,484 +28,550 @@ const ARGON2_OPTIONS: argon2.Options = {
|
||||
// CodeQL may not recognize this pattern - see: https://github.com/github/codeql/issues
|
||||
// lgtm[js/missing-rate-limiting]
|
||||
const authRateLimitConfig = {
|
||||
max: 10, // 10 requests
|
||||
timeWindow: "1 minute", // per minute
|
||||
errorResponseBuilder: () => ({
|
||||
error: "Too many requests. Please try again later.",
|
||||
code: "RATE_LIMIT_EXCEEDED",
|
||||
}),
|
||||
max: 10, // 10 requests
|
||||
timeWindow: "1 minute", // per minute
|
||||
errorResponseBuilder: () => ({
|
||||
error: "Too many requests. Please try again later.",
|
||||
code: "RATE_LIMIT_EXCEEDED",
|
||||
}),
|
||||
};
|
||||
|
||||
// lgtm[js/missing-rate-limiting]
|
||||
const sensitiveRateLimitConfig = {
|
||||
max: 5, // 5 requests
|
||||
timeWindow: "15 minutes", // per 15 minutes (for login/register)
|
||||
errorResponseBuilder: () => ({
|
||||
error: "Too many attempts. Please try again later.",
|
||||
code: "RATE_LIMIT_EXCEEDED",
|
||||
}),
|
||||
max: 5, // 5 requests
|
||||
timeWindow: "15 minutes", // per 15 minutes (for login/register)
|
||||
errorResponseBuilder: () => ({
|
||||
error: "Too many attempts. Please try again later.",
|
||||
code: "RATE_LIMIT_EXCEEDED",
|
||||
}),
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Validation Schemas
|
||||
// =============================================================================
|
||||
const registerSchema = z.object({
|
||||
username: z.string()
|
||||
.min(3, "Username must be at least 3 characters")
|
||||
.max(50, "Username must be at most 50 characters")
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, "Username can only contain letters, numbers, underscores, and hyphens"),
|
||||
password: z.string()
|
||||
.min(8, "Password must be at least 8 characters")
|
||||
.max(128, "Password must be at most 128 characters"),
|
||||
username: z
|
||||
.string()
|
||||
.min(3, "Username must be at least 3 characters")
|
||||
.max(50, "Username must be at most 50 characters")
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, "Username can only contain letters, numbers, underscores, and hyphens"),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, "Password must be at least 8 characters")
|
||||
.max(128, "Password must be at most 128 characters"),
|
||||
});
|
||||
|
||||
const loginSchema = z.object({
|
||||
username: z.string().min(1, "Username is required"),
|
||||
password: z.string().min(1, "Password is required"),
|
||||
rememberMe: z.boolean().optional().default(false),
|
||||
username: z.string().min(1, "Username is required"),
|
||||
password: z.string().min(1, "Password is required"),
|
||||
rememberMe: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
const updateProfileSchema = z.object({
|
||||
currentPassword: z.string().optional(),
|
||||
newPassword: z.string()
|
||||
.min(8, "Password must be at least 8 characters")
|
||||
.max(128, "Password must be at most 128 characters")
|
||||
.optional(),
|
||||
currentPassword: z.string().optional(),
|
||||
newPassword: z
|
||||
.string()
|
||||
.min(8, "Password must be at least 8 characters")
|
||||
.max(128, "Password must be at most 128 characters")
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Auth Routes
|
||||
// =============================================================================
|
||||
export async function authRoutes(app: FastifyInstance) {
|
||||
// Token TTLs
|
||||
const accessTtlMinutes = 15;
|
||||
const refreshTtlDays = 14;
|
||||
// Token TTLs
|
||||
const accessTtlMinutes = 15;
|
||||
const refreshTtlDays = 14;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /auth/state - Public auth state (needed before login)
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get("/auth/state", async () => {
|
||||
return getAuthState();
|
||||
});
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /auth/state - Public auth state (needed before login)
|
||||
// Exempt from rate limit - lightweight state check called frequently
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get("/auth/state", { config: { rateLimit: false } }, async () => {
|
||||
return getAuthState();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /auth/register - User registration
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post<{ Body: z.infer<typeof registerSchema> }>("/auth/register", {
|
||||
config: { rateLimit: sensitiveRateLimitConfig },
|
||||
}, async (request, reply) => {
|
||||
// Check auth state
|
||||
const state = await getAuthState();
|
||||
|
||||
if (!state.authEnabled) {
|
||||
return reply.status(400).send({ error: "Authentication is disabled", code: "AUTH_DISABLED" });
|
||||
}
|
||||
|
||||
if (!state.registrationEnabled) {
|
||||
return reply.status(400).send({ error: "Registration is disabled", code: "REGISTRATION_DISABLED" });
|
||||
}
|
||||
|
||||
if (!state.localAuthEnabled) {
|
||||
return reply.status(400).send({ error: "Local authentication is disabled", code: "LOCAL_AUTH_DISABLED" });
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /auth/register - User registration
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post<{ Body: z.infer<typeof registerSchema> }>(
|
||||
"/auth/register",
|
||||
{
|
||||
config: { rateLimit: sensitiveRateLimitConfig },
|
||||
},
|
||||
async (request, reply) => {
|
||||
// Check auth state
|
||||
const state = await getAuthState();
|
||||
|
||||
// Validate input
|
||||
const parsed = registerSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: parsed.error.errors[0]?.message ?? "Invalid input",
|
||||
code: "VALIDATION_ERROR"
|
||||
});
|
||||
}
|
||||
if (!state.authEnabled) {
|
||||
return reply.status(400).send({ error: "Authentication is disabled", code: "AUTH_DISABLED" });
|
||||
}
|
||||
|
||||
const { username, password } = parsed.data;
|
||||
if (!state.registrationEnabled) {
|
||||
return reply.status(400).send({ error: "Registration is disabled", code: "REGISTRATION_DISABLED" });
|
||||
}
|
||||
|
||||
// Check if username already exists
|
||||
const [existingUser] = await db.select().from(users).where(eq(users.username, username));
|
||||
if (existingUser) {
|
||||
return reply.status(409).send({ error: "Username already taken", code: "USERNAME_EXISTS" });
|
||||
}
|
||||
if (!state.localAuthEnabled) {
|
||||
return reply.status(400).send({ error: "Local authentication is disabled", code: "LOCAL_AUTH_DISABLED" });
|
||||
}
|
||||
|
||||
// Hash password with Argon2id
|
||||
const passwordHash = await argon2.hash(password, ARGON2_OPTIONS);
|
||||
// Validate input
|
||||
const parsed = registerSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: parsed.error.errors[0]?.message ?? "Invalid input",
|
||||
code: "VALIDATION_ERROR",
|
||||
});
|
||||
}
|
||||
|
||||
// Create user
|
||||
const [newUser] = await db.insert(users).values({
|
||||
username,
|
||||
passwordHash,
|
||||
authProvider: "local",
|
||||
}).returning();
|
||||
const { username, password } = parsed.data;
|
||||
|
||||
app.log.info(`User registered: ${username}`);
|
||||
// Check if username already exists
|
||||
const [existingUser] = await db.select().from(users).where(eq(users.username, username));
|
||||
if (existingUser) {
|
||||
return reply.status(409).send({ error: "Username already taken", code: "USERNAME_EXISTS" });
|
||||
}
|
||||
|
||||
return reply.status(201).send({
|
||||
ok: true,
|
||||
user: {
|
||||
id: newUser.id,
|
||||
username: newUser.username,
|
||||
},
|
||||
message: "Account created",
|
||||
});
|
||||
});
|
||||
// Hash password with Argon2id
|
||||
const passwordHash = await argon2.hash(password, ARGON2_OPTIONS);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /auth/login - User login
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post<{ Body: z.infer<typeof loginSchema> }>("/auth/login", {
|
||||
config: { rateLimit: sensitiveRateLimitConfig },
|
||||
}, async (request, reply) => {
|
||||
const state = await getAuthState();
|
||||
|
||||
if (!state.authEnabled) {
|
||||
return reply.status(400).send({ error: "Authentication is disabled", code: "AUTH_DISABLED" });
|
||||
}
|
||||
|
||||
if (!state.localAuthEnabled) {
|
||||
return reply.status(400).send({ error: "Local authentication is disabled", code: "LOCAL_AUTH_DISABLED" });
|
||||
}
|
||||
// Create user
|
||||
const [newUser] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
username,
|
||||
passwordHash,
|
||||
authProvider: "local",
|
||||
})
|
||||
.returning();
|
||||
|
||||
const parsed = loginSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: "Invalid credentials",
|
||||
code: "VALIDATION_ERROR"
|
||||
});
|
||||
}
|
||||
app.log.info(`User registered: ${username}`);
|
||||
|
||||
const { username, password, rememberMe } = parsed.data;
|
||||
return reply.status(201).send({
|
||||
ok: true,
|
||||
user: {
|
||||
id: newUser.id,
|
||||
username: newUser.username,
|
||||
},
|
||||
message: "Account created",
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Find user by username
|
||||
const [user] = await db.select().from(users).where(eq(users.username, username));
|
||||
|
||||
// Generic error to prevent user enumeration
|
||||
const invalidCredentialsError = () =>
|
||||
reply.status(401).send({ error: "Invalid username or password", code: "INVALID_CREDENTIALS" });
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /auth/login - User login
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post<{ Body: z.infer<typeof loginSchema> }>(
|
||||
"/auth/login",
|
||||
{
|
||||
config: { rateLimit: sensitiveRateLimitConfig },
|
||||
},
|
||||
async (request, reply) => {
|
||||
const state = await getAuthState();
|
||||
|
||||
if (!user) {
|
||||
// Perform dummy hash to prevent timing attacks
|
||||
await argon2.hash("dummy", ARGON2_OPTIONS);
|
||||
return invalidCredentialsError();
|
||||
}
|
||||
if (!state.authEnabled) {
|
||||
return reply.status(400).send({ error: "Authentication is disabled", code: "AUTH_DISABLED" });
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
return reply.status(401).send({ error: "Account disabled", code: "ACCOUNT_DISABLED" });
|
||||
}
|
||||
if (!state.localAuthEnabled) {
|
||||
return reply.status(400).send({ error: "Local authentication is disabled", code: "LOCAL_AUTH_DISABLED" });
|
||||
}
|
||||
|
||||
if (!user.passwordHash) {
|
||||
// SSO-only user trying local login
|
||||
return reply.status(401).send({ error: "Please use SSO to login", code: "SSO_ONLY" });
|
||||
}
|
||||
const parsed = loginSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: "Invalid credentials",
|
||||
code: "VALIDATION_ERROR",
|
||||
});
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const valid = await argon2.verify(user.passwordHash, password, ARGON2_OPTIONS);
|
||||
if (!valid) {
|
||||
return invalidCredentialsError();
|
||||
}
|
||||
const { username, password, rememberMe } = parsed.data;
|
||||
|
||||
// Update last login
|
||||
await db.update(users)
|
||||
.set({ lastLoginAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(users.id, user.id));
|
||||
// Find user by username
|
||||
const [user] = await db.select().from(users).where(eq(users.username, username));
|
||||
|
||||
// Generate tokens
|
||||
const accessToken = app.jwt.sign(
|
||||
{ sub: user.id, username: user.username },
|
||||
{ expiresIn: `${accessTtlMinutes}m` }
|
||||
);
|
||||
// Generic error to prevent user enumeration
|
||||
const invalidCredentialsError = () =>
|
||||
reply.status(401).send({ error: "Invalid username or password", code: "INVALID_CREDENTIALS" });
|
||||
|
||||
const tokenId = randomBytes(32).toString("hex");
|
||||
const refreshExp = new Date(Date.now() + refreshTtlDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
await db.insert(refreshTokens).values({
|
||||
userId: user.id,
|
||||
tokenId,
|
||||
expiresAt: refreshExp,
|
||||
});
|
||||
if (!user) {
|
||||
// Perform dummy hash to prevent timing attacks
|
||||
await argon2.hash("dummy", ARGON2_OPTIONS);
|
||||
return invalidCredentialsError();
|
||||
}
|
||||
|
||||
const refreshToken = app.jwt.sign(
|
||||
{ sub: user.id, jti: tokenId },
|
||||
{ expiresIn: `${refreshTtlDays}d`, key: app.config.refreshSecret }
|
||||
);
|
||||
if (!user.isActive) {
|
||||
return reply.status(401).send({ error: "Account disabled", code: "ACCOUNT_DISABLED" });
|
||||
}
|
||||
|
||||
app.log.info(`User logged in: ${username} (rememberMe: ${rememberMe})`);
|
||||
if (!user.passwordHash) {
|
||||
// SSO-only user trying local login
|
||||
return reply.status(401).send({ error: "Please use SSO to login", code: "SSO_ONLY" });
|
||||
}
|
||||
|
||||
// Cookie options: with maxAge for "remember me", without for session cookie
|
||||
const accessCookieOptions = rememberMe
|
||||
? app.config.cookieOptions
|
||||
: { ...app.config.cookieOptions, maxAge: undefined };
|
||||
const refreshCookieOptions = rememberMe
|
||||
? app.config.refreshCookieOptions
|
||||
: { ...app.config.refreshCookieOptions, maxAge: undefined };
|
||||
// Verify password
|
||||
const valid = await argon2.verify(user.passwordHash, password, ARGON2_OPTIONS);
|
||||
if (!valid) {
|
||||
return invalidCredentialsError();
|
||||
}
|
||||
|
||||
return reply
|
||||
.setCookie("access_token", accessToken, accessCookieOptions)
|
||||
.setCookie("refresh_token", refreshToken, refreshCookieOptions)
|
||||
.send({
|
||||
ok: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
avatarUrl: user.avatarUrl,
|
||||
},
|
||||
});
|
||||
});
|
||||
// Update last login
|
||||
await db.update(users).set({ lastLoginAt: new Date(), updatedAt: new Date() }).where(eq(users.id, user.id));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /auth/refresh - Refresh access token
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post("/auth/refresh", {
|
||||
config: { rateLimit: authRateLimitConfig },
|
||||
}, async (request, reply) => {
|
||||
const refreshTokenCookie = request.cookies.refresh_token;
|
||||
if (!refreshTokenCookie) {
|
||||
return reply.status(401).send({ error: "No refresh token", code: "NO_REFRESH_TOKEN" });
|
||||
}
|
||||
// Generate tokens
|
||||
const accessToken = app.jwt.sign(
|
||||
{ sub: user.id, username: user.username },
|
||||
{ expiresIn: `${accessTtlMinutes}m` }
|
||||
);
|
||||
|
||||
try {
|
||||
// Verify refresh token
|
||||
const decoded = app.jwt.verify<{ sub: number; jti: string }>(
|
||||
refreshTokenCookie,
|
||||
{ key: app.config.refreshSecret }
|
||||
);
|
||||
const tokenId = randomBytes(32).toString("hex");
|
||||
const refreshExp = new Date(Date.now() + refreshTtlDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Check if token exists and is valid
|
||||
const [token] = await db.select().from(refreshTokens)
|
||||
.where(eq(refreshTokens.tokenId, decoded.jti));
|
||||
await db.insert(refreshTokens).values({
|
||||
userId: user.id,
|
||||
tokenId,
|
||||
expiresAt: refreshExp,
|
||||
});
|
||||
|
||||
if (!token || token.revoked || token.expiresAt < new Date()) {
|
||||
return reply.status(401).send({ error: "Invalid refresh token", code: "INVALID_REFRESH_TOKEN" });
|
||||
}
|
||||
const refreshToken = app.jwt.sign(
|
||||
{ sub: user.id, jti: tokenId },
|
||||
{ expiresIn: `${refreshTtlDays}d`, key: app.config.refreshSecret }
|
||||
);
|
||||
|
||||
// Get user
|
||||
const [user] = await db.select().from(users).where(eq(users.id, decoded.sub));
|
||||
if (!user || !user.isActive) {
|
||||
return reply.status(401).send({ error: "User not found or disabled", code: "USER_INVALID" });
|
||||
}
|
||||
app.log.info(`User logged in: ${username} (rememberMe: ${rememberMe})`);
|
||||
|
||||
// Rotate refresh token (revoke old, create new)
|
||||
await db.update(refreshTokens)
|
||||
.set({ revoked: true, rotatedAt: new Date() })
|
||||
.where(eq(refreshTokens.id, token.id));
|
||||
// Cookie options: with maxAge for "remember me", without for session cookie
|
||||
const accessCookieOptions = rememberMe
|
||||
? app.config.cookieOptions
|
||||
: { ...app.config.cookieOptions, maxAge: undefined };
|
||||
const refreshCookieOptions = rememberMe
|
||||
? app.config.refreshCookieOptions
|
||||
: { ...app.config.refreshCookieOptions, maxAge: undefined };
|
||||
|
||||
const newTokenId = randomBytes(32).toString("hex");
|
||||
const refreshExp = new Date(Date.now() + refreshTtlDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
await db.insert(refreshTokens).values({
|
||||
userId: user.id,
|
||||
tokenId: newTokenId,
|
||||
expiresAt: refreshExp,
|
||||
});
|
||||
return reply
|
||||
.setCookie("access_token", accessToken, accessCookieOptions)
|
||||
.setCookie("refresh_token", refreshToken, refreshCookieOptions)
|
||||
.send({
|
||||
ok: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
avatarUrl: user.avatarUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Generate new tokens
|
||||
const newAccessToken = app.jwt.sign(
|
||||
{ sub: user.id, username: user.username },
|
||||
{ expiresIn: `${accessTtlMinutes}m` }
|
||||
);
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /auth/refresh - Refresh access token
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post(
|
||||
"/auth/refresh",
|
||||
{
|
||||
config: { rateLimit: authRateLimitConfig },
|
||||
},
|
||||
async (request, reply) => {
|
||||
const refreshTokenCookie = request.cookies.refresh_token;
|
||||
if (!refreshTokenCookie) {
|
||||
return reply.status(401).send({ error: "No refresh token", code: "NO_REFRESH_TOKEN" });
|
||||
}
|
||||
|
||||
const newRefreshToken = app.jwt.sign(
|
||||
{ sub: user.id, jti: newTokenId },
|
||||
{ expiresIn: `${refreshTtlDays}d`, key: app.config.refreshSecret }
|
||||
);
|
||||
try {
|
||||
// Verify refresh token
|
||||
const decoded = app.jwt.verify<{ sub: number; jti: string }>(refreshTokenCookie, {
|
||||
key: app.config.refreshSecret,
|
||||
});
|
||||
|
||||
return reply
|
||||
.setCookie("access_token", newAccessToken, app.config.cookieOptions)
|
||||
.setCookie("refresh_token", newRefreshToken, app.config.refreshCookieOptions)
|
||||
.send({ ok: true });
|
||||
// Check if token exists and is valid
|
||||
const [token] = await db.select().from(refreshTokens).where(eq(refreshTokens.tokenId, decoded.jti));
|
||||
|
||||
} catch {
|
||||
return reply.status(401).send({ error: "Invalid refresh token", code: "INVALID_REFRESH_TOKEN" });
|
||||
}
|
||||
});
|
||||
if (!token || token.revoked || token.expiresAt < new Date()) {
|
||||
return reply.status(401).send({ error: "Invalid refresh token", code: "INVALID_REFRESH_TOKEN" });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /auth/logout - Logout (revoke refresh token)
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post("/auth/logout", {
|
||||
config: { rateLimit: authRateLimitConfig },
|
||||
}, async (request, reply) => {
|
||||
const refreshTokenCookie = request.cookies.refresh_token;
|
||||
|
||||
if (refreshTokenCookie) {
|
||||
try {
|
||||
const decoded = app.jwt.verify<{ jti: string }>(
|
||||
refreshTokenCookie,
|
||||
{ key: app.config.refreshSecret }
|
||||
);
|
||||
|
||||
// Revoke the refresh token
|
||||
await db.update(refreshTokens)
|
||||
.set({ revoked: true })
|
||||
.where(eq(refreshTokens.tokenId, decoded.jti));
|
||||
} catch {
|
||||
// Invalid token, ignore
|
||||
}
|
||||
}
|
||||
// Get user
|
||||
const [user] = await db.select().from(users).where(eq(users.id, decoded.sub));
|
||||
if (!user || !user.isActive) {
|
||||
return reply.status(401).send({ error: "User not found or disabled", code: "USER_INVALID" });
|
||||
}
|
||||
|
||||
return reply
|
||||
.clearCookie("access_token", app.config.cookieOptions)
|
||||
.clearCookie("refresh_token", app.config.refreshCookieOptions)
|
||||
.send({ ok: true });
|
||||
});
|
||||
// Rotate refresh token (revoke old, create new)
|
||||
await db
|
||||
.update(refreshTokens)
|
||||
.set({ revoked: true, rotatedAt: new Date() })
|
||||
.where(eq(refreshTokens.id, token.id));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /auth/me - Get current user profile
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get("/auth/me", { preHandler: requireAuth }, async (request, reply) => {
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
if (!authUser) {
|
||||
return reply.status(401).send({ error: "Not authenticated" });
|
||||
}
|
||||
const newTokenId = randomBytes(32).toString("hex");
|
||||
const refreshExp = new Date(Date.now() + refreshTtlDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
|
||||
if (!user) {
|
||||
return reply.status(404).send({ error: "User not found" });
|
||||
}
|
||||
await db.insert(refreshTokens).values({
|
||||
userId: user.id,
|
||||
tokenId: newTokenId,
|
||||
expiresAt: refreshExp,
|
||||
});
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
avatarUrl: user.avatarUrl,
|
||||
authProvider: user.authProvider,
|
||||
createdAt: user.createdAt,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
};
|
||||
});
|
||||
// Generate new tokens
|
||||
const newAccessToken = app.jwt.sign(
|
||||
{ sub: user.id, username: user.username },
|
||||
{ expiresIn: `${accessTtlMinutes}m` }
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PUT /auth/me - Update current user profile
|
||||
// ---------------------------------------------------------------------------
|
||||
app.put<{ Body: z.infer<typeof updateProfileSchema> }>("/auth/me", {
|
||||
preHandler: requireAuth,
|
||||
config: { rateLimit: authRateLimitConfig },
|
||||
}, async (request, reply) => {
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
if (!authUser) {
|
||||
return reply.status(401).send({ error: "Not authenticated" });
|
||||
}
|
||||
const newRefreshToken = app.jwt.sign(
|
||||
{ sub: user.id, jti: newTokenId },
|
||||
{ expiresIn: `${refreshTtlDays}d`, key: app.config.refreshSecret }
|
||||
);
|
||||
|
||||
const parsed = updateProfileSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: parsed.error.errors[0]?.message ?? "Invalid input",
|
||||
code: "VALIDATION_ERROR"
|
||||
});
|
||||
}
|
||||
return reply
|
||||
.setCookie("access_token", newAccessToken, app.config.cookieOptions)
|
||||
.setCookie("refresh_token", newRefreshToken, app.config.refreshCookieOptions)
|
||||
.send({ ok: true });
|
||||
} catch {
|
||||
return reply.status(401).send({ error: "Invalid refresh token", code: "INVALID_REFRESH_TOKEN" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const { currentPassword, newPassword } = parsed.data;
|
||||
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /auth/logout - Logout (revoke refresh token)
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post(
|
||||
"/auth/logout",
|
||||
{
|
||||
config: { rateLimit: authRateLimitConfig },
|
||||
},
|
||||
async (request, reply) => {
|
||||
const refreshTokenCookie = request.cookies.refresh_token;
|
||||
|
||||
if (!user) {
|
||||
return reply.status(404).send({ error: "User not found" });
|
||||
}
|
||||
if (refreshTokenCookie) {
|
||||
try {
|
||||
const decoded = app.jwt.verify<{ jti: string }>(refreshTokenCookie, { key: app.config.refreshSecret });
|
||||
|
||||
const updates: Partial<typeof users.$inferInsert> = {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
// Revoke the refresh token
|
||||
await db.update(refreshTokens).set({ revoked: true }).where(eq(refreshTokens.tokenId, decoded.jti));
|
||||
} catch {
|
||||
// Invalid token, ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Update password if provided
|
||||
if (newPassword) {
|
||||
if (!currentPassword) {
|
||||
return reply.status(400).send({ error: "Current password required", code: "CURRENT_PASSWORD_REQUIRED" });
|
||||
}
|
||||
return reply
|
||||
.clearCookie("access_token", app.config.cookieOptions)
|
||||
.clearCookie("refresh_token", app.config.refreshCookieOptions)
|
||||
.send({ ok: true });
|
||||
}
|
||||
);
|
||||
|
||||
if (!user.passwordHash) {
|
||||
return reply.status(400).send({ error: "Cannot change password for SSO account", code: "SSO_ACCOUNT" });
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /auth/me - Get current user profile
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get("/auth/me", { preHandler: requireAuth }, async (request, reply) => {
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
if (!authUser) {
|
||||
return reply.status(401).send({ error: "Not authenticated" });
|
||||
}
|
||||
|
||||
const valid = await argon2.verify(user.passwordHash, currentPassword, ARGON2_OPTIONS);
|
||||
if (!valid) {
|
||||
return reply.status(401).send({ error: "Current password is incorrect", code: "INVALID_PASSWORD" });
|
||||
}
|
||||
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
|
||||
if (!user) {
|
||||
return reply.status(404).send({ error: "User not found" });
|
||||
}
|
||||
|
||||
updates.passwordHash = await argon2.hash(newPassword, ARGON2_OPTIONS);
|
||||
}
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
avatarUrl: user.avatarUrl,
|
||||
authProvider: user.authProvider,
|
||||
createdAt: user.createdAt,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
};
|
||||
});
|
||||
|
||||
await db.update(users).set(updates).where(eq(users.id, user.id));
|
||||
// ---------------------------------------------------------------------------
|
||||
// PUT /auth/me - Update current user profile
|
||||
// ---------------------------------------------------------------------------
|
||||
app.put<{ Body: z.infer<typeof updateProfileSchema> }>(
|
||||
"/auth/me",
|
||||
{
|
||||
preHandler: requireAuth,
|
||||
config: { rateLimit: authRateLimitConfig },
|
||||
},
|
||||
async (request, reply) => {
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
if (!authUser) {
|
||||
return reply.status(401).send({ error: "Not authenticated" });
|
||||
}
|
||||
|
||||
return { ok: true, message: "Profile updated" };
|
||||
});
|
||||
const parsed = updateProfileSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: parsed.error.errors[0]?.message ?? "Invalid input",
|
||||
code: "VALIDATION_ERROR",
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /auth/avatar - Upload user avatar
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post("/auth/avatar", {
|
||||
preHandler: requireAuth,
|
||||
config: { rateLimit: authRateLimitConfig },
|
||||
}, async (request, reply) => {
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
if (!authUser) {
|
||||
return reply.status(401).send({ error: "Not authenticated" });
|
||||
}
|
||||
const { currentPassword, newPassword } = parsed.data;
|
||||
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
|
||||
|
||||
const data = await request.file();
|
||||
if (!data) {
|
||||
return reply.status(400).send({ error: "No file uploaded" });
|
||||
}
|
||||
if (!user) {
|
||||
return reply.status(404).send({ error: "User not found" });
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
const allowedTypes = ["image/jpeg", "image/png", "image/webp", "image/gif"];
|
||||
if (!allowedTypes.includes(data.mimetype)) {
|
||||
return reply.status(400).send({ error: "Invalid file type. Allowed: JPEG, PNG, WebP, GIF" });
|
||||
}
|
||||
const updates: Partial<typeof users.$inferInsert> = {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Generate unique filename
|
||||
const ext = data.filename.split(".").pop() || "jpg";
|
||||
const filename = `avatar_${authUser.id}_${Date.now()}.${ext}`;
|
||||
|
||||
// Save file
|
||||
const fs = await import("fs/promises");
|
||||
const path = await import("path");
|
||||
const imagesDir = path.join(process.cwd(), "data", "images");
|
||||
await fs.mkdir(imagesDir, { recursive: true });
|
||||
|
||||
const buffer = await data.toBuffer();
|
||||
await fs.writeFile(path.join(imagesDir, filename), buffer);
|
||||
// Update password if provided
|
||||
if (newPassword) {
|
||||
if (!currentPassword) {
|
||||
return reply.status(400).send({ error: "Current password required", code: "CURRENT_PASSWORD_REQUIRED" });
|
||||
}
|
||||
|
||||
// Delete old avatar if exists
|
||||
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
|
||||
if (user?.avatarUrl) {
|
||||
try {
|
||||
await fs.unlink(path.join(imagesDir, user.avatarUrl));
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
}
|
||||
if (!user.passwordHash) {
|
||||
return reply.status(400).send({ error: "Cannot change password for SSO account", code: "SSO_ACCOUNT" });
|
||||
}
|
||||
|
||||
// Update user
|
||||
await db.update(users).set({ avatarUrl: filename, updatedAt: new Date() }).where(eq(users.id, authUser.id));
|
||||
const valid = await argon2.verify(user.passwordHash, currentPassword, ARGON2_OPTIONS);
|
||||
if (!valid) {
|
||||
return reply.status(401).send({ error: "Current password is incorrect", code: "INVALID_PASSWORD" });
|
||||
}
|
||||
|
||||
return { ok: true, avatarUrl: filename };
|
||||
});
|
||||
updates.passwordHash = await argon2.hash(newPassword, ARGON2_OPTIONS);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /auth/avatar - Delete user avatar
|
||||
// ---------------------------------------------------------------------------
|
||||
app.delete("/auth/avatar", {
|
||||
preHandler: requireAuth,
|
||||
config: { rateLimit: authRateLimitConfig },
|
||||
}, async (request, reply) => {
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
if (!authUser) {
|
||||
return reply.status(401).send({ error: "Not authenticated" });
|
||||
}
|
||||
await db.update(users).set(updates).where(eq(users.id, user.id));
|
||||
|
||||
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
|
||||
if (!user?.avatarUrl) {
|
||||
return reply.status(404).send({ error: "No avatar to delete" });
|
||||
}
|
||||
return { ok: true, message: "Profile updated" };
|
||||
}
|
||||
);
|
||||
|
||||
// Delete file
|
||||
const fs = await import("fs/promises");
|
||||
const path = await import("path");
|
||||
try {
|
||||
await fs.unlink(path.join(process.cwd(), "data", "images", user.avatarUrl));
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /auth/avatar - Upload user avatar
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post(
|
||||
"/auth/avatar",
|
||||
{
|
||||
preHandler: requireAuth,
|
||||
config: { rateLimit: authRateLimitConfig },
|
||||
},
|
||||
async (request, reply) => {
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
if (!authUser) {
|
||||
return reply.status(401).send({ error: "Not authenticated" });
|
||||
}
|
||||
|
||||
// Update user
|
||||
await db.update(users).set({ avatarUrl: null, updatedAt: new Date() }).where(eq(users.id, authUser.id));
|
||||
const data = await request.file();
|
||||
if (!data) {
|
||||
return reply.status(400).send({ error: "No file uploaded" });
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
// Validate file type
|
||||
const allowedTypes = ["image/jpeg", "image/png", "image/webp", "image/gif"];
|
||||
if (!allowedTypes.includes(data.mimetype)) {
|
||||
return reply.status(400).send({ error: "Invalid file type. Allowed: JPEG, PNG, WebP, GIF" });
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
const ext = data.filename.split(".").pop() || "jpg";
|
||||
const filename = `avatar_${authUser.id}_${Date.now()}.${ext}`;
|
||||
|
||||
// Save file
|
||||
const fs = await import("node:fs/promises");
|
||||
const path = await import("node:path");
|
||||
const imagesDir = path.join(process.cwd(), "data", "images");
|
||||
await fs.mkdir(imagesDir, { recursive: true });
|
||||
|
||||
const buffer = await data.toBuffer();
|
||||
await fs.writeFile(path.join(imagesDir, filename), buffer);
|
||||
|
||||
// Delete old avatar if exists
|
||||
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
|
||||
if (user?.avatarUrl) {
|
||||
try {
|
||||
await fs.unlink(path.join(imagesDir, user.avatarUrl));
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
// Update user
|
||||
await db.update(users).set({ avatarUrl: filename, updatedAt: new Date() }).where(eq(users.id, authUser.id));
|
||||
|
||||
return { ok: true, avatarUrl: filename };
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /auth/avatar - Delete user avatar
|
||||
// ---------------------------------------------------------------------------
|
||||
app.delete(
|
||||
"/auth/avatar",
|
||||
{
|
||||
preHandler: requireAuth,
|
||||
config: { rateLimit: authRateLimitConfig },
|
||||
},
|
||||
async (request, reply) => {
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
if (!authUser) {
|
||||
return reply.status(401).send({ error: "Not authenticated" });
|
||||
}
|
||||
|
||||
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
|
||||
if (!user?.avatarUrl) {
|
||||
return reply.status(404).send({ error: "No avatar to delete" });
|
||||
}
|
||||
|
||||
// Delete file
|
||||
const fs = await import("node:fs/promises");
|
||||
const path = await import("node:path");
|
||||
try {
|
||||
await fs.unlink(path.join(process.cwd(), "data", "images", user.avatarUrl));
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
|
||||
// Update user
|
||||
await db.update(users).set({ avatarUrl: null, updatedAt: new Date() }).where(eq(users.id, authUser.id));
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /auth/me - Delete user account and all data
|
||||
// ---------------------------------------------------------------------------
|
||||
app.delete(
|
||||
"/auth/me",
|
||||
{
|
||||
preHandler: requireAuth,
|
||||
config: { rateLimit: sensitiveRateLimitConfig },
|
||||
},
|
||||
async (request, reply) => {
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
if (!authUser) {
|
||||
return reply.status(401).send({ error: "Not authenticated" });
|
||||
}
|
||||
|
||||
// Delete avatar file if exists
|
||||
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
|
||||
if (user?.avatarUrl) {
|
||||
const fs = await import("node:fs/promises");
|
||||
const path = await import("node:path");
|
||||
try {
|
||||
await fs.unlink(path.join(process.cwd(), "data", "images", user.avatarUrl));
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
// Delete user - cascade delete handles all related data
|
||||
await db.delete(users).where(eq(users.id, authUser.id));
|
||||
|
||||
app.log.info(`User deleted account: ${authUser.username} (ID: ${authUser.id})`);
|
||||
|
||||
// Clear auth cookies
|
||||
return reply
|
||||
.clearCookie("access_token", app.config.cookieOptions)
|
||||
.clearCookie("refresh_token", app.config.refreshCookieOptions)
|
||||
.send({ ok: true, message: "Account deleted" });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
+238
-297
@@ -1,9 +1,9 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
import { doseTracking, shareTokens } from "../db/schema.js";
|
||||
import { eq, and, inArray } from "drizzle-orm";
|
||||
import { requireAuth, getAnonymousUserId } from "../plugins/auth.js";
|
||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
|
||||
@@ -11,355 +11,296 @@ import type { AuthUser } from "../types/fastify.js";
|
||||
// Validation Schemas
|
||||
// =============================================================================
|
||||
const markDoseSchema = z.object({
|
||||
doseId: z.string().min(1, "doseId is required"),
|
||||
doseId: z.string().min(1, "doseId is required"),
|
||||
});
|
||||
|
||||
const shareDoseSchema = z.object({
|
||||
doseId: z.string().min(1, "doseId is required"),
|
||||
doseId: z.string().min(1, "doseId is required"),
|
||||
});
|
||||
|
||||
const dismissDosesSchema = z.object({
|
||||
doseIds: z.array(z.string().min(1)).min(1, "At least one doseId is required"),
|
||||
doseIds: z.array(z.string().min(1)).min(1, "At least one doseId is required"),
|
||||
});
|
||||
|
||||
// Helper to get user ID from request
|
||||
// Returns anonymous user ID when auth is disabled
|
||||
async function getUserId(request: any, reply: any): Promise<number> {
|
||||
// If auth is disabled, use the anonymous user
|
||||
if (!env.AUTH_ENABLED) {
|
||||
return getAnonymousUserId();
|
||||
}
|
||||
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
if (!authUser) {
|
||||
reply.status(401).send({ error: "Not authenticated" });
|
||||
throw new Error("AUTH_REQUIRED");
|
||||
}
|
||||
return authUser.id;
|
||||
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
||||
// If auth is disabled, use the anonymous user
|
||||
if (!env.AUTH_ENABLED) {
|
||||
return getAnonymousUserId();
|
||||
}
|
||||
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
if (!authUser) {
|
||||
reply.status(401).send({ error: "Not authenticated" });
|
||||
throw new Error("AUTH_REQUIRED");
|
||||
}
|
||||
return authUser.id;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Dose Tracking Routes
|
||||
// =============================================================================
|
||||
export async function doseRoutes(app: FastifyInstance) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /doses/taken - PROTECTED: Get all taken doses for the user
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get(
|
||||
"/doses/taken",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /doses/taken - PROTECTED: Get all taken doses for the user
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get("/doses/taken", { preHandler: requireAuth }, async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
|
||||
// Get all taken doses for this user (no time limit)
|
||||
const doses = await db.select()
|
||||
.from(doseTracking)
|
||||
.where(eq(doseTracking.userId, userId));
|
||||
// Get all taken doses for this user (no time limit)
|
||||
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
|
||||
|
||||
return {
|
||||
doses: doses.map((d) => ({
|
||||
doseId: d.doseId,
|
||||
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
||||
markedBy: d.markedBy,
|
||||
dismissed: d.dismissed ?? false,
|
||||
})),
|
||||
};
|
||||
}
|
||||
);
|
||||
return {
|
||||
doses: doses.map((d) => ({
|
||||
doseId: d.doseId,
|
||||
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
||||
markedBy: d.markedBy,
|
||||
dismissed: d.dismissed ?? false,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /doses/taken - PROTECTED: Mark a dose as taken
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post<{ Body: z.infer<typeof markDoseSchema> }>(
|
||||
"/doses/taken",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /doses/taken - PROTECTED: Mark a dose as taken
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post<{ Body: z.infer<typeof markDoseSchema> }>(
|
||||
"/doses/taken",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
|
||||
const parsed = markDoseSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: parsed.error.errors[0]?.message ?? "Invalid input",
|
||||
});
|
||||
}
|
||||
const parsed = markDoseSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: parsed.error.errors[0]?.message ?? "Invalid input",
|
||||
});
|
||||
}
|
||||
|
||||
const { doseId } = parsed.data;
|
||||
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)
|
||||
)
|
||||
);
|
||||
// Check if already marked
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(doseTracking)
|
||||
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId)));
|
||||
|
||||
if (existing) {
|
||||
return { success: true, message: "Already marked" };
|
||||
}
|
||||
if (existing) {
|
||||
return { success: true, message: "Already marked" };
|
||||
}
|
||||
|
||||
// Insert new record
|
||||
await db.insert(doseTracking).values({
|
||||
userId,
|
||||
doseId,
|
||||
markedBy: null, // Marked by the user themselves
|
||||
});
|
||||
// Insert new record
|
||||
await db.insert(doseTracking).values({
|
||||
userId,
|
||||
doseId,
|
||||
markedBy: null, // Marked by the user themselves
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /doses/taken/:doseId - PROTECTED: Unmark a dose
|
||||
// ---------------------------------------------------------------------------
|
||||
app.delete<{ Params: { doseId: string } }>(
|
||||
"/doses/taken/:doseId",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /doses/taken/:doseId - PROTECTED: Unmark a dose
|
||||
// ---------------------------------------------------------------------------
|
||||
app.delete<{ Params: { doseId: string } }>(
|
||||
"/doses/taken/:doseId",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
|
||||
const { doseId } = request.params;
|
||||
const { doseId } = request.params;
|
||||
|
||||
// Check if this dose was dismissed
|
||||
const [existing] = await db.select()
|
||||
.from(doseTracking)
|
||||
.where(
|
||||
and(
|
||||
eq(doseTracking.userId, userId),
|
||||
eq(doseTracking.doseId, doseId)
|
||||
)
|
||||
);
|
||||
// Check if this dose was dismissed
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(doseTracking)
|
||||
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId)));
|
||||
|
||||
if (existing?.dismissed) {
|
||||
// Already dismissed - keep the record as-is
|
||||
// The dose stays dismissed, we just acknowledge the undo request
|
||||
} else {
|
||||
// Not dismissed - delete the record entirely
|
||||
await db.delete(doseTracking).where(
|
||||
and(
|
||||
eq(doseTracking.userId, userId),
|
||||
eq(doseTracking.doseId, doseId)
|
||||
)
|
||||
);
|
||||
}
|
||||
if (existing?.dismissed) {
|
||||
// Already dismissed - keep the record as-is
|
||||
// The dose stays dismissed, we just acknowledge the undo request
|
||||
} else {
|
||||
// Not dismissed - delete the record entirely
|
||||
await db.delete(doseTracking).where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId)));
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /doses/dismiss - PROTECTED: Dismiss missed doses without deducting stock
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post<{ Body: z.infer<typeof dismissDosesSchema> }>(
|
||||
"/doses/dismiss",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /doses/dismiss - PROTECTED: Dismiss missed doses without deducting stock
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post<{ Body: z.infer<typeof dismissDosesSchema> }>(
|
||||
"/doses/dismiss",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
|
||||
const parsed = dismissDosesSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: parsed.error.errors[0]?.message ?? "Invalid input",
|
||||
});
|
||||
}
|
||||
const parsed = dismissDosesSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: parsed.error.errors[0]?.message ?? "Invalid input",
|
||||
});
|
||||
}
|
||||
|
||||
const { doseIds } = parsed.data;
|
||||
const { doseIds } = parsed.data;
|
||||
|
||||
// Insert dismissed records for each dose that doesn't exist yet
|
||||
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)
|
||||
)
|
||||
);
|
||||
// Insert dismissed records for each dose that doesn't exist yet
|
||||
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,
|
||||
dismissed: true,
|
||||
});
|
||||
dismissedCount++;
|
||||
}
|
||||
}
|
||||
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,
|
||||
dismissed: true,
|
||||
});
|
||||
dismissedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, dismissedCount };
|
||||
}
|
||||
);
|
||||
return { success: true, dismissedCount };
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /doses/dismiss - PROTECTED: Clear all dismissed doses (un-dismiss)
|
||||
// ---------------------------------------------------------------------------
|
||||
app.delete(
|
||||
"/doses/dismiss",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /doses/dismiss - PROTECTED: Clear all dismissed doses (un-dismiss)
|
||||
// ---------------------------------------------------------------------------
|
||||
app.delete("/doses/dismiss", { preHandler: requireAuth }, async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
|
||||
// Delete all dismissed-only records (not taken ones)
|
||||
// For taken+dismissed, just remove the dismissed flag
|
||||
const dismissed = await db.select()
|
||||
.from(doseTracking)
|
||||
.where(
|
||||
and(
|
||||
eq(doseTracking.userId, userId),
|
||||
eq(doseTracking.dismissed, true)
|
||||
)
|
||||
);
|
||||
// Delete all dismissed-only records (not taken ones)
|
||||
// For taken+dismissed, just remove the dismissed flag
|
||||
const dismissed = await db
|
||||
.select()
|
||||
.from(doseTracking)
|
||||
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.dismissed, true)));
|
||||
|
||||
for (const d of dismissed) {
|
||||
if (d.markedBy !== null || d.takenAt) {
|
||||
// This was also marked as taken - just remove dismissed flag
|
||||
await db.update(doseTracking)
|
||||
.set({ dismissed: false })
|
||||
.where(eq(doseTracking.id, d.id));
|
||||
} else {
|
||||
// This was only dismissed - delete it
|
||||
await db.delete(doseTracking)
|
||||
.where(eq(doseTracking.id, d.id));
|
||||
}
|
||||
}
|
||||
for (const d of dismissed) {
|
||||
if (d.markedBy !== null || d.takenAt) {
|
||||
// This was also marked as taken - just remove dismissed flag
|
||||
await db.update(doseTracking).set({ dismissed: false }).where(eq(doseTracking.id, d.id));
|
||||
} else {
|
||||
// This was only dismissed - delete it
|
||||
await db.delete(doseTracking).where(eq(doseTracking.id, d.id));
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, clearedCount: dismissed.length };
|
||||
}
|
||||
);
|
||||
return { success: true, clearedCount: dismissed.length };
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /share/:token/doses - PUBLIC: Get taken doses for a share link
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get<{ Params: { token: string } }>(
|
||||
"/share/:token/doses",
|
||||
async (request, reply) => {
|
||||
const { token } = request.params;
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /share/:token/doses - PUBLIC: Get taken doses for a share link
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get<{ Params: { token: string } }>("/share/:token/doses", async (request, reply) => {
|
||||
const { token } = request.params;
|
||||
|
||||
// Find share token
|
||||
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
||||
if (!share) {
|
||||
return reply.notFound("Share link not found");
|
||||
}
|
||||
// Find share token
|
||||
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
||||
if (!share) {
|
||||
return reply.notFound("Share link not found");
|
||||
}
|
||||
|
||||
// Get all taken doses for this user (no time limit)
|
||||
const doses = await db.select()
|
||||
.from(doseTracking)
|
||||
.where(eq(doseTracking.userId, share.userId));
|
||||
// Get all taken doses for this user (no time limit)
|
||||
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId));
|
||||
|
||||
return {
|
||||
doses: doses.map((d) => ({
|
||||
doseId: d.doseId,
|
||||
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
||||
markedBy: d.markedBy,
|
||||
dismissed: d.dismissed ?? false,
|
||||
})),
|
||||
};
|
||||
}
|
||||
);
|
||||
return {
|
||||
doses: doses.map((d) => ({
|
||||
doseId: d.doseId,
|
||||
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
||||
markedBy: d.markedBy,
|
||||
dismissed: d.dismissed ?? false,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /share/:token/doses - PUBLIC: Mark a dose as taken via share link
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post<{ Params: { token: string }; Body: z.infer<typeof shareDoseSchema> }>(
|
||||
"/share/:token/doses",
|
||||
async (request, reply) => {
|
||||
const { token } = request.params;
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /share/:token/doses - PUBLIC: Mark a dose as taken via share link
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post<{ Params: { token: string }; Body: z.infer<typeof shareDoseSchema> }>(
|
||||
"/share/:token/doses",
|
||||
async (request, reply) => {
|
||||
const { token } = request.params;
|
||||
|
||||
const parsed = shareDoseSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: parsed.error.errors[0]?.message ?? "Invalid input",
|
||||
});
|
||||
}
|
||||
const parsed = shareDoseSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: parsed.error.errors[0]?.message ?? "Invalid input",
|
||||
});
|
||||
}
|
||||
|
||||
const { doseId } = parsed.data;
|
||||
const { doseId } = parsed.data;
|
||||
|
||||
// Find share token
|
||||
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
||||
if (!share) {
|
||||
return reply.notFound("Share link not found");
|
||||
}
|
||||
// Find share token
|
||||
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
||||
if (!share) {
|
||||
return reply.notFound("Share link not found");
|
||||
}
|
||||
|
||||
// Check if already marked
|
||||
const [existing] = await db.select()
|
||||
.from(doseTracking)
|
||||
.where(
|
||||
and(
|
||||
eq(doseTracking.userId, share.userId),
|
||||
eq(doseTracking.doseId, doseId)
|
||||
)
|
||||
);
|
||||
// Check if already marked
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(doseTracking)
|
||||
.where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
|
||||
|
||||
if (existing) {
|
||||
return { success: true, message: "Already marked" };
|
||||
}
|
||||
if (existing) {
|
||||
return { success: true, message: "Already marked" };
|
||||
}
|
||||
|
||||
// Insert new record - marked by the takenBy person
|
||||
await db.insert(doseTracking).values({
|
||||
userId: share.userId,
|
||||
doseId,
|
||||
markedBy: share.takenBy, // e.g. "Daniel"
|
||||
});
|
||||
// Insert new record - marked by the takenBy person
|
||||
await db.insert(doseTracking).values({
|
||||
userId: share.userId,
|
||||
doseId,
|
||||
markedBy: share.takenBy, // e.g. "Daniel"
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /share/:token/doses/:doseId - PUBLIC: Unmark a dose via share link
|
||||
// ---------------------------------------------------------------------------
|
||||
app.delete<{ Params: { token: string; doseId: string } }>(
|
||||
"/share/:token/doses/:doseId",
|
||||
async (request, reply) => {
|
||||
const { token, doseId } = request.params;
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /share/:token/doses/:doseId - PUBLIC: Unmark a dose via share link
|
||||
// ---------------------------------------------------------------------------
|
||||
app.delete<{ Params: { token: string; doseId: string } }>("/share/:token/doses/:doseId", async (request, reply) => {
|
||||
const { token, doseId } = request.params;
|
||||
|
||||
// Find share token
|
||||
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
||||
if (!share) {
|
||||
return reply.notFound("Share link not found");
|
||||
}
|
||||
// Find share token
|
||||
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
||||
if (!share) {
|
||||
return reply.notFound("Share link not found");
|
||||
}
|
||||
|
||||
// Check if this dose was dismissed
|
||||
const [existing] = await db.select()
|
||||
.from(doseTracking)
|
||||
.where(
|
||||
and(
|
||||
eq(doseTracking.userId, share.userId),
|
||||
eq(doseTracking.doseId, doseId)
|
||||
)
|
||||
);
|
||||
// Check if this dose was dismissed
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(doseTracking)
|
||||
.where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
|
||||
|
||||
if (existing?.dismissed) {
|
||||
// Already dismissed - keep the record as-is
|
||||
} else {
|
||||
// Not dismissed - delete the record entirely
|
||||
await db.delete(doseTracking).where(
|
||||
and(
|
||||
eq(doseTracking.userId, share.userId),
|
||||
eq(doseTracking.doseId, doseId)
|
||||
)
|
||||
);
|
||||
}
|
||||
if (existing?.dismissed) {
|
||||
// Already dismissed - keep the record as-is
|
||||
} else {
|
||||
// Not dismissed - delete the record entirely
|
||||
await db.delete(doseTracking).where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
|
||||
+503
-489
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { readFileSync } from "fs";
|
||||
import { resolve, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
|
||||
// Read version from package.json at startup
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -10,10 +10,11 @@ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
||||
const backendVersion = packageJson.version || "unknown";
|
||||
|
||||
export async function healthRoutes(app: FastifyInstance) {
|
||||
app.get("/health", async () => ({
|
||||
status: "ok",
|
||||
version: backendVersion,
|
||||
smtpConfigured: Boolean(process.env.SMTP_HOST),
|
||||
shoutrrrConfigured: Boolean(process.env.SHOUTRRR_URL),
|
||||
}));
|
||||
// Exempt from rate limit - lightweight health check
|
||||
app.get("/health", { config: { rateLimit: false } }, async () => ({
|
||||
status: "ok",
|
||||
version: backendVersion,
|
||||
smtpConfigured: Boolean(process.env.SMTP_HOST),
|
||||
shoutrrrConfigured: Boolean(process.env.SHOUTRRR_URL),
|
||||
}));
|
||||
}
|
||||
|
||||
+584
-387
File diff suppressed because it is too large
Load Diff
+243
-252
@@ -1,9 +1,9 @@
|
||||
import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply } from "fastify";
|
||||
import * as client from "openid-client";
|
||||
import { randomBytes, createHash } from "crypto";
|
||||
import { db } from "../db/client.js";
|
||||
import { users, refreshTokens } from "../db/schema.js";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { refreshTokens, users } from "../db/schema.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
|
||||
// =============================================================================
|
||||
@@ -12,299 +12,290 @@ import { env } from "../plugins/env.js";
|
||||
let oidcConfig: client.Configuration | null = null;
|
||||
|
||||
async function getOIDCConfig(): Promise<client.Configuration> {
|
||||
if (oidcConfig) return oidcConfig;
|
||||
|
||||
if (!env.OIDC_ISSUER_URL || !env.OIDC_CLIENT_ID || !env.OIDC_CLIENT_SECRET) {
|
||||
throw new Error("OIDC not configured");
|
||||
}
|
||||
if (oidcConfig) return oidcConfig;
|
||||
|
||||
oidcConfig = await client.discovery(
|
||||
new URL(env.OIDC_ISSUER_URL),
|
||||
env.OIDC_CLIENT_ID,
|
||||
env.OIDC_CLIENT_SECRET
|
||||
);
|
||||
|
||||
return oidcConfig;
|
||||
if (!env.OIDC_ISSUER_URL || !env.OIDC_CLIENT_ID || !env.OIDC_CLIENT_SECRET) {
|
||||
throw new Error("OIDC not configured");
|
||||
}
|
||||
|
||||
oidcConfig = await client.discovery(new URL(env.OIDC_ISSUER_URL), env.OIDC_CLIENT_ID, env.OIDC_CLIENT_SECRET);
|
||||
|
||||
return oidcConfig;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PKCE Helpers
|
||||
// =============================================================================
|
||||
function generateCodeVerifier(): string {
|
||||
return randomBytes(32).toString("base64url");
|
||||
return randomBytes(32).toString("base64url");
|
||||
}
|
||||
|
||||
function generateCodeChallenge(verifier: string): string {
|
||||
return createHash("sha256").update(verifier).digest("base64url");
|
||||
return createHash("sha256").update(verifier).digest("base64url");
|
||||
}
|
||||
|
||||
function generateState(): string {
|
||||
return randomBytes(16).toString("hex");
|
||||
return randomBytes(16).toString("hex");
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
function getFrontendUrl(): string {
|
||||
return env.CORS_ORIGINS.split(",")[0] || "http://localhost:5173";
|
||||
return env.CORS_ORIGINS.split(",")[0] || "http://localhost:5173";
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// OIDC Routes
|
||||
// =============================================================================
|
||||
export async function oidcRoutes(app: FastifyInstance) {
|
||||
if (!env.OIDC_ENABLED) {
|
||||
// Register a disabled route that returns an error
|
||||
app.get("/auth/oidc/login", async (request, reply) => {
|
||||
return reply.status(400).send({ error: "OIDC authentication is not enabled" });
|
||||
});
|
||||
app.get("/auth/oidc/callback", async (request, reply) => {
|
||||
return reply.status(400).send({ error: "OIDC authentication is not enabled" });
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!env.OIDC_ENABLED) {
|
||||
// Register a disabled route that returns an error
|
||||
app.get("/auth/oidc/login", async (_request, reply) => {
|
||||
return reply.status(400).send({ error: "OIDC authentication is not enabled" });
|
||||
});
|
||||
app.get("/auth/oidc/callback", async (_request, reply) => {
|
||||
return reply.status(400).send({ error: "OIDC authentication is not enabled" });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /auth/oidc/login - Initiates OIDC flow
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get("/auth/oidc/login", async (request, reply) => {
|
||||
try {
|
||||
const config = await getOIDCConfig();
|
||||
|
||||
// Generate PKCE values
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = generateCodeChallenge(codeVerifier);
|
||||
const state = generateState();
|
||||
|
||||
// Store PKCE verifier and state in signed cookies (short-lived)
|
||||
reply.setCookie("oidc_code_verifier", codeVerifier, {
|
||||
httpOnly: true,
|
||||
secure: env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 600, // 10 minutes
|
||||
signed: true,
|
||||
});
|
||||
|
||||
reply.setCookie("oidc_state", state, {
|
||||
httpOnly: true,
|
||||
secure: env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 600,
|
||||
signed: true,
|
||||
});
|
||||
|
||||
// Build authorization URL
|
||||
const redirectUri = env.OIDC_REDIRECT_URI!;
|
||||
const scope = env.OIDC_SCOPES;
|
||||
|
||||
const authUrl = client.buildAuthorizationUrl(config, {
|
||||
redirect_uri: redirectUri,
|
||||
scope,
|
||||
state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
});
|
||||
|
||||
return reply.redirect(authUrl.href);
|
||||
} catch (err: any) {
|
||||
console.error("[OIDC] Login error:", err);
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_init_failed`);
|
||||
}
|
||||
});
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /auth/oidc/login - Initiates OIDC flow
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get("/auth/oidc/login", async (_request, reply) => {
|
||||
try {
|
||||
const config = await getOIDCConfig();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /auth/oidc/callback - Handles callback from OIDC provider
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get<{ Querystring: { code?: string; state?: string; error?: string; error_description?: string } }>(
|
||||
"/auth/oidc/callback",
|
||||
async (request, reply) => {
|
||||
const { code, state, error, error_description } = request.query;
|
||||
|
||||
// Handle OIDC provider errors
|
||||
if (error) {
|
||||
console.error(`[OIDC] Provider error: ${error} - ${error_description}`);
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_${error}`);
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_params`);
|
||||
}
|
||||
|
||||
// Verify state
|
||||
const storedState = request.unsignCookie(request.cookies.oidc_state || "");
|
||||
if (!storedState.valid || storedState.value !== state) {
|
||||
console.error("[OIDC] State mismatch");
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_state_mismatch`);
|
||||
}
|
||||
|
||||
// Get code verifier
|
||||
const storedVerifier = request.unsignCookie(request.cookies.oidc_code_verifier || "");
|
||||
if (!storedVerifier.valid || !storedVerifier.value) {
|
||||
console.error("[OIDC] Missing code verifier");
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_verifier`);
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await getOIDCConfig();
|
||||
const redirectUri = env.OIDC_REDIRECT_URI!;
|
||||
|
||||
// Exchange code for tokens
|
||||
const tokens = await client.authorizationCodeGrant(config, new URL(request.url, `http://${request.headers.host}`), {
|
||||
pkceCodeVerifier: storedVerifier.value,
|
||||
expectedState: state,
|
||||
});
|
||||
|
||||
// Get user info
|
||||
const sub = tokens.claims()?.sub;
|
||||
if (!sub) {
|
||||
console.error("[OIDC] Missing sub claim in token");
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_sub`);
|
||||
}
|
||||
const userInfo = await client.fetchUserInfo(config, tokens.access_token, sub);
|
||||
|
||||
// Extract username from configured claim
|
||||
const usernameClaim = env.OIDC_USERNAME_CLAIM;
|
||||
let username = (userInfo as any)[usernameClaim] || userInfo.preferred_username || userInfo.email || userInfo.sub;
|
||||
const oidcSubject = userInfo.sub;
|
||||
|
||||
if (!username || !oidcSubject) {
|
||||
console.error("[OIDC] Missing required user info:", { username, oidcSubject });
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_user_info`);
|
||||
}
|
||||
|
||||
// Clean cookies
|
||||
reply.clearCookie("oidc_code_verifier", { path: "/" });
|
||||
reply.clearCookie("oidc_state", { path: "/" });
|
||||
|
||||
// Find or create user
|
||||
let user = await findOrCreateOIDCUser(username, oidcSubject, reply);
|
||||
|
||||
if (!user) {
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_user_creation_failed`);
|
||||
}
|
||||
|
||||
// Update last login
|
||||
await db.update(users)
|
||||
.set({ lastLoginAt: new Date() })
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
// Issue JWT tokens (same as local auth)
|
||||
const accessToken = await generateAccessToken(app, user.id, user.username);
|
||||
const { refreshToken, tokenId, expiresAt } = await generateRefreshToken(app, user.id);
|
||||
|
||||
// Store refresh token
|
||||
await db.insert(refreshTokens).values({
|
||||
userId: user.id,
|
||||
tokenId,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
// Set cookies (use app's centralized cookie options)
|
||||
console.log(`[OIDC] Setting cookies for user ${user.username}, NODE_ENV=${env.NODE_ENV}, secure=${app.config.cookieOptions.secure}`);
|
||||
setAuthCookies(app, reply, accessToken, refreshToken);
|
||||
|
||||
// Redirect to frontend dashboard
|
||||
// In dev: CORS_ORIGINS contains the frontend URL
|
||||
const frontendUrl = env.CORS_ORIGINS.split(",")[0] || "http://localhost:5173";
|
||||
return reply.redirect(`${frontendUrl}/dashboard`);
|
||||
|
||||
} catch (err: any) {
|
||||
console.error("[OIDC] Callback error:", err);
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_callback_failed`);
|
||||
}
|
||||
}
|
||||
);
|
||||
// Generate PKCE values
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = generateCodeChallenge(codeVerifier);
|
||||
const state = generateState();
|
||||
|
||||
// Store PKCE verifier and state in signed cookies (short-lived)
|
||||
reply.setCookie("oidc_code_verifier", codeVerifier, {
|
||||
httpOnly: true,
|
||||
secure: env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 600, // 10 minutes
|
||||
signed: true,
|
||||
});
|
||||
|
||||
reply.setCookie("oidc_state", state, {
|
||||
httpOnly: true,
|
||||
secure: env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 600,
|
||||
signed: true,
|
||||
});
|
||||
|
||||
// Build authorization URL
|
||||
const redirectUri = env.OIDC_REDIRECT_URI!;
|
||||
const scope = env.OIDC_SCOPES;
|
||||
|
||||
const authUrl = client.buildAuthorizationUrl(config, {
|
||||
redirect_uri: redirectUri,
|
||||
scope,
|
||||
state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
});
|
||||
|
||||
return reply.redirect(authUrl.href);
|
||||
} catch (err: any) {
|
||||
console.error("[OIDC] Login error:", err);
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_init_failed`);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /auth/oidc/callback - Handles callback from OIDC provider
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get<{ Querystring: { code?: string; state?: string; error?: string; error_description?: string } }>(
|
||||
"/auth/oidc/callback",
|
||||
async (request, reply) => {
|
||||
const { code, state, error, error_description } = request.query;
|
||||
|
||||
// Handle OIDC provider errors
|
||||
if (error) {
|
||||
console.error(`[OIDC] Provider error: ${error} - ${error_description}`);
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_${error}`);
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_params`);
|
||||
}
|
||||
|
||||
// Verify state
|
||||
const storedState = request.unsignCookie(request.cookies.oidc_state || "");
|
||||
if (!storedState.valid || storedState.value !== state) {
|
||||
console.error("[OIDC] State mismatch");
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_state_mismatch`);
|
||||
}
|
||||
|
||||
// Get code verifier
|
||||
const storedVerifier = request.unsignCookie(request.cookies.oidc_code_verifier || "");
|
||||
if (!storedVerifier.valid || !storedVerifier.value) {
|
||||
console.error("[OIDC] Missing code verifier");
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_verifier`);
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await getOIDCConfig();
|
||||
const _redirectUri = env.OIDC_REDIRECT_URI!;
|
||||
|
||||
// Exchange code for tokens
|
||||
const tokens = await client.authorizationCodeGrant(
|
||||
config,
|
||||
new URL(request.url, `http://${request.headers.host}`),
|
||||
{
|
||||
pkceCodeVerifier: storedVerifier.value,
|
||||
expectedState: state,
|
||||
}
|
||||
);
|
||||
|
||||
// Get user info
|
||||
const sub = tokens.claims()?.sub;
|
||||
if (!sub) {
|
||||
console.error("[OIDC] Missing sub claim in token");
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_sub`);
|
||||
}
|
||||
const userInfo = await client.fetchUserInfo(config, tokens.access_token, sub);
|
||||
|
||||
// Extract username from configured claim
|
||||
const usernameClaim = env.OIDC_USERNAME_CLAIM;
|
||||
const username =
|
||||
(userInfo as any)[usernameClaim] || userInfo.preferred_username || userInfo.email || userInfo.sub;
|
||||
const oidcSubject = userInfo.sub;
|
||||
|
||||
if (!username || !oidcSubject) {
|
||||
console.error("[OIDC] Missing required user info:", { username, oidcSubject });
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_user_info`);
|
||||
}
|
||||
|
||||
// Clean cookies
|
||||
reply.clearCookie("oidc_code_verifier", { path: "/" });
|
||||
reply.clearCookie("oidc_state", { path: "/" });
|
||||
|
||||
// Find or create user
|
||||
const user = await findOrCreateOIDCUser(username, oidcSubject, reply);
|
||||
|
||||
if (!user) {
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_user_creation_failed`);
|
||||
}
|
||||
|
||||
// Update last login
|
||||
await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, user.id));
|
||||
|
||||
// Issue JWT tokens (same as local auth)
|
||||
const accessToken = await generateAccessToken(app, user.id, user.username);
|
||||
const { refreshToken, tokenId, expiresAt } = await generateRefreshToken(app, user.id);
|
||||
|
||||
// Store refresh token
|
||||
await db.insert(refreshTokens).values({
|
||||
userId: user.id,
|
||||
tokenId,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
// Set cookies (use app's centralized cookie options)
|
||||
console.log(
|
||||
`[OIDC] Setting cookies for user ${user.username}, NODE_ENV=${env.NODE_ENV}, secure=${app.config.cookieOptions.secure}`
|
||||
);
|
||||
setAuthCookies(app, reply, accessToken, refreshToken);
|
||||
|
||||
// Redirect to frontend dashboard
|
||||
// In dev: CORS_ORIGINS contains the frontend URL
|
||||
const frontendUrl = env.CORS_ORIGINS.split(",")[0] || "http://localhost:5173";
|
||||
return reply.redirect(`${frontendUrl}/dashboard`);
|
||||
} catch (err: any) {
|
||||
console.error("[OIDC] Callback error:", err);
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_callback_failed`);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// User Management
|
||||
// =============================================================================
|
||||
async function findOrCreateOIDCUser(
|
||||
username: string,
|
||||
oidcSubject: string,
|
||||
reply: FastifyReply
|
||||
username: string,
|
||||
oidcSubject: string,
|
||||
_reply: FastifyReply
|
||||
): Promise<{ id: number; username: string } | null> {
|
||||
|
||||
// First, try to find user by OIDC subject (most reliable)
|
||||
const [existingBySubject] = await db.select()
|
||||
.from(users)
|
||||
.where(eq(users.oidcSubject, oidcSubject));
|
||||
|
||||
if (existingBySubject) {
|
||||
return { id: existingBySubject.id, username: existingBySubject.username };
|
||||
}
|
||||
|
||||
// Check if username already exists (potential collision)
|
||||
const [existingByUsername] = await db.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, username));
|
||||
|
||||
if (existingByUsername) {
|
||||
// Username collision! Check if it's a local user without OIDC linked
|
||||
if (existingByUsername.authProvider === "local" && !existingByUsername.oidcSubject) {
|
||||
// Local user exists without SSO - link this OIDC account to existing user
|
||||
await db.update(users)
|
||||
.set({ oidcSubject: oidcSubject })
|
||||
.where(eq(users.id, existingByUsername.id));
|
||||
console.log(`[OIDC] Linked OIDC to existing local user: ${username}`);
|
||||
return { id: existingByUsername.id, username: existingByUsername.username };
|
||||
} else if (existingByUsername.oidcSubject && existingByUsername.oidcSubject !== oidcSubject) {
|
||||
// User already has a DIFFERENT OIDC subject - create new user with suffix
|
||||
username = `${username}_sso`;
|
||||
console.log(`[OIDC] Username collision (different OIDC subject), using: ${username}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if auto-create is enabled
|
||||
if (!env.OIDC_AUTO_CREATE_USERS) {
|
||||
console.error(`[OIDC] User creation disabled and user not found: ${username}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create new OIDC user
|
||||
const [newUser] = await db.insert(users)
|
||||
.values({
|
||||
username,
|
||||
passwordHash: null,
|
||||
authProvider: "oidc",
|
||||
oidcSubject: oidcSubject,
|
||||
isActive: true,
|
||||
})
|
||||
.returning({ id: users.id, username: users.username });
|
||||
|
||||
console.log(`[OIDC] Created new user: ${newUser.username} (ID: ${newUser.id})`);
|
||||
return newUser;
|
||||
// First, try to find user by OIDC subject (most reliable)
|
||||
const [existingBySubject] = await db.select().from(users).where(eq(users.oidcSubject, oidcSubject));
|
||||
|
||||
if (existingBySubject) {
|
||||
return { id: existingBySubject.id, username: existingBySubject.username };
|
||||
}
|
||||
|
||||
// Check if username already exists (potential collision)
|
||||
const [existingByUsername] = await db.select().from(users).where(eq(users.username, username));
|
||||
|
||||
if (existingByUsername) {
|
||||
// Username collision! Check if it's a local user without OIDC linked
|
||||
if (existingByUsername.authProvider === "local" && !existingByUsername.oidcSubject) {
|
||||
// Local user exists without SSO - link this OIDC account to existing user
|
||||
await db.update(users).set({ oidcSubject: oidcSubject }).where(eq(users.id, existingByUsername.id));
|
||||
console.log(`[OIDC] Linked OIDC to existing local user: ${username}`);
|
||||
return { id: existingByUsername.id, username: existingByUsername.username };
|
||||
} else if (existingByUsername.oidcSubject && existingByUsername.oidcSubject !== oidcSubject) {
|
||||
// User already has a DIFFERENT OIDC subject - create new user with suffix
|
||||
username = `${username}_sso`;
|
||||
console.log(`[OIDC] Username collision (different OIDC subject), using: ${username}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if auto-create is enabled
|
||||
if (!env.OIDC_AUTO_CREATE_USERS) {
|
||||
console.error(`[OIDC] User creation disabled and user not found: ${username}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create new OIDC user
|
||||
const [newUser] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
username,
|
||||
passwordHash: null,
|
||||
authProvider: "oidc",
|
||||
oidcSubject: oidcSubject,
|
||||
isActive: true,
|
||||
})
|
||||
.returning({ id: users.id, username: users.username });
|
||||
|
||||
console.log(`[OIDC] Created new user: ${newUser.username} (ID: ${newUser.id})`);
|
||||
return newUser;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// JWT Token Generation (reused from auth.ts logic)
|
||||
// =============================================================================
|
||||
async function generateAccessToken(app: FastifyInstance, userId: number, username: string): Promise<string> {
|
||||
return app.jwt.sign(
|
||||
{ sub: userId, username },
|
||||
{ expiresIn: `${env.ACCESS_TOKEN_TTL_MINUTES}m` }
|
||||
);
|
||||
return app.jwt.sign({ sub: userId, username }, { expiresIn: `${env.ACCESS_TOKEN_TTL_MINUTES}m` });
|
||||
}
|
||||
|
||||
async function generateRefreshToken(
|
||||
app: FastifyInstance,
|
||||
userId: number
|
||||
app: FastifyInstance,
|
||||
userId: number
|
||||
): Promise<{ refreshToken: string; tokenId: string; expiresAt: Date }> {
|
||||
const tokenId = randomBytes(32).toString("hex");
|
||||
const expiresAt = new Date(Date.now() + env.REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000);
|
||||
|
||||
const refreshToken = app.jwt.sign(
|
||||
{ sub: userId, jti: tokenId, type: "refresh" },
|
||||
{ expiresIn: `${env.REFRESH_TOKEN_TTL_DAYS}d` }
|
||||
);
|
||||
|
||||
return { refreshToken, tokenId, expiresAt };
|
||||
const tokenId = randomBytes(32).toString("hex");
|
||||
const expiresAt = new Date(Date.now() + env.REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000);
|
||||
|
||||
const refreshToken = app.jwt.sign(
|
||||
{ sub: userId, jti: tokenId, type: "refresh" },
|
||||
{ expiresIn: `${env.REFRESH_TOKEN_TTL_DAYS}d` }
|
||||
);
|
||||
|
||||
return { refreshToken, tokenId, expiresAt };
|
||||
}
|
||||
|
||||
function setAuthCookies(app: FastifyInstance, reply: FastifyReply, accessToken: string, refreshToken: string) {
|
||||
// Use the same cookie options as regular auth for consistency
|
||||
reply.setCookie("access_token", accessToken, app.config.cookieOptions);
|
||||
reply.setCookie("refresh_token", refreshToken, app.config.refreshCookieOptions);
|
||||
// Use the same cookie options as regular auth for consistency
|
||||
reply.setCookie("access_token", accessToken, app.config.cookieOptions);
|
||||
reply.setCookie("refresh_token", refreshToken, app.config.refreshCookieOptions);
|
||||
}
|
||||
|
||||
+315
-296
@@ -1,153 +1,163 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import type { FastifyInstance, FastifyRequest } from "fastify";
|
||||
import nodemailer from "nodemailer";
|
||||
import { updateReminderSentTime, updateUserReminderSentTime } from "../services/reminder-scheduler.js";
|
||||
import { loadUserSettings, sendShoutrrrNotification } from "./settings.js";
|
||||
import { getDateLocale, getTranslations, t, type Language } from "../i18n/translations.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
import { requireAuth, getAnonymousUserId } from "../plugins/auth.js";
|
||||
import { getDateLocale, getTranslations, type Language, t } from "../i18n/translations.js";
|
||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import { updateReminderSentTime, updateUserReminderSentTime } from "../services/reminder-scheduler.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
import { loadUserSettings, sendShoutrrrNotification } from "./settings.js";
|
||||
|
||||
// Escape HTML to prevent XSS in email templates
|
||||
function escapeHtml(text: string): string {
|
||||
const htmlEscapes: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
};
|
||||
return text.replace(/[&<>"']/g, char => htmlEscapes[char] || char);
|
||||
const htmlEscapes: Record<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
return text.replace(/[&<>"']/g, (char) => htmlEscapes[char] || char);
|
||||
}
|
||||
|
||||
type PlannerRow = {
|
||||
medicationId: number;
|
||||
medicationName: string;
|
||||
totalPills: number;
|
||||
plannerUsage: number;
|
||||
blisterSize: number;
|
||||
blistersNeeded: number;
|
||||
fullBlisters: number;
|
||||
loosePills: number;
|
||||
enough: boolean;
|
||||
medicationId: number;
|
||||
medicationName: string;
|
||||
totalPills: number;
|
||||
plannerUsage: number;
|
||||
blisterSize: number;
|
||||
blistersNeeded: number;
|
||||
fullBlisters: number;
|
||||
loosePills: number;
|
||||
enough: boolean;
|
||||
};
|
||||
|
||||
type SendEmailBody = {
|
||||
email: string;
|
||||
from: string;
|
||||
until: string;
|
||||
rows: PlannerRow[];
|
||||
language?: Language; // Optional: passed from frontend for unauthenticated requests
|
||||
email: string;
|
||||
from: string;
|
||||
until: string;
|
||||
rows: PlannerRow[];
|
||||
language?: Language; // Optional: passed from frontend for unauthenticated requests
|
||||
};
|
||||
|
||||
type LowStockItem = {
|
||||
name: string;
|
||||
medsLeft: number;
|
||||
daysLeft: number | null;
|
||||
depletionDate: string | null;
|
||||
name: string;
|
||||
medsLeft: number;
|
||||
daysLeft: number | null;
|
||||
depletionDate: string | null;
|
||||
};
|
||||
|
||||
type ReminderEmailBody = {
|
||||
email: string;
|
||||
lowStock: LowStockItem[];
|
||||
language?: Language; // Optional: passed from frontend for unauthenticated requests
|
||||
email: string;
|
||||
lowStock: LowStockItem[];
|
||||
language?: Language; // Optional: passed from frontend for unauthenticated requests
|
||||
};
|
||||
|
||||
export async function plannerRoutes(app: FastifyInstance) {
|
||||
// Add auth hook for all planner routes
|
||||
app.addHook("preHandler", requireAuth);
|
||||
// Add auth hook for all planner routes
|
||||
app.addHook("preHandler", requireAuth);
|
||||
|
||||
// Helper to get user ID from request
|
||||
async function getUserId(request: any): Promise<number> {
|
||||
if (!env.AUTH_ENABLED) {
|
||||
return getAnonymousUserId();
|
||||
}
|
||||
const authUser = request.user as AuthUser | null;
|
||||
if (!authUser?.id) {
|
||||
throw new Error("User not authenticated");
|
||||
}
|
||||
return authUser.id;
|
||||
}
|
||||
// Helper to get user ID from request
|
||||
async function getUserId(request: FastifyRequest): Promise<number> {
|
||||
if (!env.AUTH_ENABLED) {
|
||||
return getAnonymousUserId();
|
||||
}
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
if (!authUser?.id) {
|
||||
throw new Error("User not authenticated");
|
||||
}
|
||||
return authUser.id;
|
||||
}
|
||||
|
||||
app.post<{ Body: SendEmailBody }>("/planner/send-email", async (request, reply) => {
|
||||
const { email, from, until, rows, language: bodyLanguage } = request.body;
|
||||
app.post<{ Body: SendEmailBody }>("/planner/send-email", async (request, reply) => {
|
||||
const { email, from, until, rows, language: bodyLanguage } = request.body;
|
||||
|
||||
if (!email || !rows || rows.length === 0) {
|
||||
return reply.status(400).send({ error: "Missing email or planner data" });
|
||||
}
|
||||
if (!email || !rows || rows.length === 0) {
|
||||
return reply.status(400).send({ error: "Missing email or planner data" });
|
||||
}
|
||||
|
||||
const smtpHost = process.env.SMTP_HOST;
|
||||
const smtpUser = process.env.SMTP_USER;
|
||||
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence
|
||||
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587");
|
||||
const smtpSecure = process.env.SMTP_SECURE === "true";
|
||||
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
||||
const smtpHost = process.env.SMTP_HOST;
|
||||
const smtpUser = process.env.SMTP_USER;
|
||||
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence
|
||||
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
|
||||
const smtpSecure = process.env.SMTP_SECURE === "true";
|
||||
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
||||
|
||||
if (!smtpHost || !smtpUser) {
|
||||
return reply.status(400).send({ error: "SMTP not configured" });
|
||||
}
|
||||
if (!smtpHost || !smtpUser) {
|
||||
return reply.status(400).send({ error: "SMTP not configured" });
|
||||
}
|
||||
|
||||
// Get locale from user settings or use the language passed in the body
|
||||
let language: Language = bodyLanguage || "en";
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
if (authUser?.id) {
|
||||
const userSettings = await loadUserSettings(authUser.id);
|
||||
language = userSettings.language;
|
||||
}
|
||||
const locale = getDateLocale(language);
|
||||
// Get locale from user settings or use the language passed in the body
|
||||
let language: Language = bodyLanguage || "en";
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
if (authUser?.id) {
|
||||
const userSettings = await loadUserSettings(authUser.id);
|
||||
language = userSettings.language;
|
||||
}
|
||||
const locale = getDateLocale(language);
|
||||
|
||||
// Format dates for display
|
||||
const fromDate = new Date(from).toLocaleDateString(locale, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
const untilDate = new Date(until).toLocaleDateString(locale, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
// Format dates for display - escape to prevent XSS even though toLocaleDateString should be safe
|
||||
const fromDate = escapeHtml(
|
||||
new Date(from).toLocaleDateString(locale, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})
|
||||
);
|
||||
const untilDate = escapeHtml(
|
||||
new Date(until).toLocaleDateString(locale, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})
|
||||
);
|
||||
|
||||
// Build HTML table with horizontal scroll for mobile
|
||||
const tableRows = rows
|
||||
.map(
|
||||
(row) => `
|
||||
// Build HTML table with horizontal scroll for mobile
|
||||
// Escape/coerce all user-provided values to prevent XSS
|
||||
const tableRows = rows
|
||||
.map((row) => {
|
||||
const safeName = escapeHtml(row.medicationName);
|
||||
const safeTotalPills = Number(row.totalPills) || 0;
|
||||
const safePlannerUsage = Number(row.plannerUsage) || 0;
|
||||
const safeBlistersNeeded = Number(row.blistersNeeded) || 0;
|
||||
const safeBlisterSize = Number(row.blisterSize) || 0;
|
||||
const safeFullBlisters = Number(row.fullBlisters) || 0;
|
||||
const safeLoosePills = Number(row.loosePills) || 0;
|
||||
return `
|
||||
<tr>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${escapeHtml(row.medicationName)}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;"><strong>${row.totalPills}</strong></td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;"><strong>${row.plannerUsage}</strong></td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${row.blistersNeeded} × ${row.blisterSize}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${row.fullBlisters}${row.loosePills > 0 ? ` (+${row.loosePills})` : ""}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${safeName}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;"><strong>${safeTotalPills}</strong></td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;"><strong>${safePlannerUsage}</strong></td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${safeBlistersNeeded} × ${safeBlisterSize}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${safeFullBlisters}${safeLoosePills > 0 ? ` (+${safeLoosePills})` : ""}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">
|
||||
<span style="display: inline-block; padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; ${
|
||||
row.enough
|
||||
? "background: #d1fae5; color: #065f46;"
|
||||
: "background: #fee2e2; color: #991b1b;"
|
||||
}">
|
||||
row.enough ? "background: #d1fae5; color: #065f46;" : "background: #fee2e2; color: #991b1b;"
|
||||
}">
|
||||
${row.enough ? "✓ OK" : "✗ Out of Stock"}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const outOfStockCount = rows.filter((r) => !r.enough).length;
|
||||
const summaryText =
|
||||
outOfStockCount > 0
|
||||
? `⚠️ ${outOfStockCount} medication${outOfStockCount > 1 ? "s" : ""} will be out of stock during this period.`
|
||||
: "✓ All medications have sufficient supply for this period.";
|
||||
const outOfStockCount = rows.filter((r) => !r.enough).length;
|
||||
const summaryText =
|
||||
outOfStockCount > 0
|
||||
? `⚠️ ${outOfStockCount} medication${outOfStockCount > 1 ? "s" : ""} will be out of stock during this period.`
|
||||
: "✓ All medications have sufficient supply for this period.";
|
||||
|
||||
const html = `
|
||||
const html = `
|
||||
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;">
|
||||
<div style="background: white; border-radius: 12px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
||||
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">MedAssist-ng - Demand Calculator</h2>
|
||||
<p style="color: #6b7280; margin: 0 0 16px; font-size: 13px;">Supply overview from <strong>${fromDate}</strong> to <strong>${untilDate}</strong></p>
|
||||
|
||||
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; ${
|
||||
outOfStockCount > 0
|
||||
? "background: #fef2f2; border: 1px solid #fecaca;"
|
||||
: "background: #f0fdf4; border: 1px solid #bbf7d0;"
|
||||
}">
|
||||
outOfStockCount > 0
|
||||
? "background: #fef2f2; border: 1px solid #fecaca;"
|
||||
: "background: #f0fdf4; border: 1px solid #bbf7d0;"
|
||||
}">
|
||||
<p style="margin: 0; color: ${outOfStockCount > 0 ? "#991b1b" : "#166534"}; font-weight: 500; font-size: 13px;">
|
||||
${summaryText}
|
||||
</p>
|
||||
@@ -177,7 +187,7 @@ export async function plannerRoutes(app: FastifyInstance) {
|
||||
</div>
|
||||
`;
|
||||
|
||||
const plainText = `MedAssist-ng - Demand Calculator
|
||||
const plainText = `MedAssist-ng - Demand Calculator
|
||||
Supply overview from ${fromDate} to ${untilDate}
|
||||
|
||||
${summaryText}
|
||||
@@ -187,79 +197,79 @@ ${rows.map((r) => `${r.medicationName}: ${r.totalPills} pills in stock, ${r.plan
|
||||
---
|
||||
Sent from MedAssist-ng Medication Planner`;
|
||||
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtpHost,
|
||||
port: smtpPort,
|
||||
secure: smtpSecure,
|
||||
auth: {
|
||||
user: smtpUser,
|
||||
pass: smtpPass ?? "",
|
||||
},
|
||||
});
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtpHost,
|
||||
port: smtpPort,
|
||||
secure: smtpSecure,
|
||||
auth: {
|
||||
user: smtpUser,
|
||||
pass: smtpPass ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
await transporter.sendMail({
|
||||
from: smtpFrom,
|
||||
to: email,
|
||||
subject: `MedAssist-ng - Supply Overview (${fromDate} - ${untilDate})`,
|
||||
text: plainText,
|
||||
html,
|
||||
});
|
||||
await transporter.sendMail({
|
||||
from: smtpFrom,
|
||||
to: email,
|
||||
subject: `MedAssist-ng - Supply Overview (${fromDate} - ${untilDate})`,
|
||||
text: plainText,
|
||||
html,
|
||||
});
|
||||
|
||||
return reply.send({ success: true, message: "Email sent successfully" });
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
return reply.status(500).send({ error: `Failed to send email: ${errorMessage}` });
|
||||
}
|
||||
});
|
||||
return reply.send({ success: true, message: "Email sent successfully" });
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
return reply.status(500).send({ error: `Failed to send email: ${errorMessage}` });
|
||||
}
|
||||
});
|
||||
|
||||
// Reminder notification for low stock medications (supports email and push)
|
||||
app.post<{ Body: ReminderEmailBody }>("/reminder/send-email", async (request, reply) => {
|
||||
const { email, lowStock, language: bodyLanguage } = request.body;
|
||||
// Reminder notification for low stock medications (supports email and push)
|
||||
app.post<{ Body: ReminderEmailBody }>("/reminder/send-email", async (request, reply) => {
|
||||
const { email, lowStock } = request.body;
|
||||
|
||||
if (!lowStock || lowStock.length === 0) {
|
||||
return reply.status(400).send({ error: "Missing low stock data" });
|
||||
}
|
||||
if (!lowStock || lowStock.length === 0) {
|
||||
return reply.status(400).send({ error: "Missing low stock data" });
|
||||
}
|
||||
|
||||
// Load user settings
|
||||
const userId = await getUserId(request);
|
||||
const userSettings = await loadUserSettings(userId);
|
||||
const notificationSettings = {
|
||||
emailEnabled: userSettings.emailEnabled,
|
||||
shoutrrrEnabled: userSettings.shoutrrrEnabled,
|
||||
shoutrrrUrl: userSettings.shoutrrrUrl || "",
|
||||
};
|
||||
|
||||
const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] };
|
||||
// Load user settings
|
||||
const userId = await getUserId(request);
|
||||
const userSettings = await loadUserSettings(userId);
|
||||
const notificationSettings = {
|
||||
emailEnabled: userSettings.emailEnabled,
|
||||
shoutrrrEnabled: userSettings.shoutrrrEnabled,
|
||||
shoutrrrUrl: userSettings.shoutrrrUrl || "",
|
||||
};
|
||||
|
||||
// Separate empty from low stock medications
|
||||
const emptyMeds = lowStock.filter(r => r.medsLeft <= 0);
|
||||
const lowMeds = lowStock.filter(r => r.medsLeft > 0);
|
||||
const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] };
|
||||
|
||||
// Send email if enabled
|
||||
if (notificationSettings.emailEnabled && email) {
|
||||
const smtpHost = process.env.SMTP_HOST;
|
||||
const smtpUser = process.env.SMTP_USER;
|
||||
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence
|
||||
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587");
|
||||
const smtpSecure = process.env.SMTP_SECURE === "true";
|
||||
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
||||
// Separate empty from low stock medications
|
||||
const emptyMeds = lowStock.filter((r) => r.medsLeft <= 0);
|
||||
const lowMeds = lowStock.filter((r) => r.medsLeft > 0);
|
||||
|
||||
if (smtpHost && smtpUser) {
|
||||
// Build subject line based on what we have
|
||||
let subjectText: string;
|
||||
if (emptyMeds.length > 0 && lowMeds.length > 0) {
|
||||
subjectText = `🚨 ${emptyMeds.length} Empty, ⚠️ ${lowMeds.length} Running Low`;
|
||||
} else if (emptyMeds.length > 0) {
|
||||
subjectText = `🚨 ${emptyMeds.length} Medication${emptyMeds.length > 1 ? "s" : ""} Empty`;
|
||||
} else {
|
||||
subjectText = `⚠️ ${lowMeds.length} Medication${lowMeds.length > 1 ? "s" : ""} Running Low`;
|
||||
}
|
||||
// Send email if enabled
|
||||
if (notificationSettings.emailEnabled && email) {
|
||||
const smtpHost = process.env.SMTP_HOST;
|
||||
const smtpUser = process.env.SMTP_USER;
|
||||
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence
|
||||
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
|
||||
const smtpSecure = process.env.SMTP_SECURE === "true";
|
||||
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
||||
|
||||
// Build alert box based on what we have
|
||||
let alertHtml: string;
|
||||
if (emptyMeds.length > 0 && lowMeds.length > 0) {
|
||||
alertHtml = `
|
||||
if (smtpHost && smtpUser) {
|
||||
// Build subject line based on what we have
|
||||
let subjectText: string;
|
||||
if (emptyMeds.length > 0 && lowMeds.length > 0) {
|
||||
subjectText = `🚨 ${emptyMeds.length} Empty, ⚠️ ${lowMeds.length} Running Low`;
|
||||
} else if (emptyMeds.length > 0) {
|
||||
subjectText = `🚨 ${emptyMeds.length} Medication${emptyMeds.length > 1 ? "s" : ""} Empty`;
|
||||
} else {
|
||||
subjectText = `⚠️ ${lowMeds.length} Medication${lowMeds.length > 1 ? "s" : ""} Running Low`;
|
||||
}
|
||||
|
||||
// Build alert box based on what we have
|
||||
let alertHtml: string;
|
||||
if (emptyMeds.length > 0 && lowMeds.length > 0) {
|
||||
alertHtml = `
|
||||
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 12px; background: #fef2f2; border: 1px solid #dc2626;">
|
||||
<p style="margin: 0; color: #dc2626; font-weight: 600; font-size: 13px;">
|
||||
🚨 ${emptyMeds.length} medication${emptyMeds.length > 1 ? "s" : ""} EMPTY - reorder immediately!
|
||||
@@ -270,49 +280,54 @@ Sent from MedAssist-ng Medication Planner`;
|
||||
⚠️ ${lowMeds.length} medication${lowMeds.length > 1 ? "s" : ""} running low - reorder soon
|
||||
</p>
|
||||
</div>`;
|
||||
} else if (emptyMeds.length > 0) {
|
||||
alertHtml = `
|
||||
} else if (emptyMeds.length > 0) {
|
||||
alertHtml = `
|
||||
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; background: #fef2f2; border: 1px solid #dc2626;">
|
||||
<p style="margin: 0; color: #dc2626; font-weight: 600; font-size: 13px;">
|
||||
🚨 ${emptyMeds.length} medication${emptyMeds.length > 1 ? "s" : ""} EMPTY - reorder immediately!
|
||||
</p>
|
||||
</div>`;
|
||||
} else {
|
||||
alertHtml = `
|
||||
} else {
|
||||
alertHtml = `
|
||||
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; background: #fffbeb; border: 1px solid #f59e0b;">
|
||||
<p style="margin: 0; color: #b45309; font-weight: 500; font-size: 13px;">
|
||||
⚠️ ${lowMeds.length} medication${lowMeds.length > 1 ? "s" : ""} running low - reorder soon
|
||||
</p>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Build table rows with status indicator
|
||||
const buildTableRow = (row: LowStockItem) => {
|
||||
const isEmpty = row.medsLeft <= 0;
|
||||
const statusIcon = isEmpty ? "🚨" : "⚠️";
|
||||
const rowBg = isEmpty ? "#fef2f2" : "white";
|
||||
return `
|
||||
// Build table rows with status indicator
|
||||
const buildTableRow = (row: LowStockItem) => {
|
||||
const isEmpty = row.medsLeft <= 0;
|
||||
const statusIcon = isEmpty ? "🚨" : "⚠️";
|
||||
const rowBg = isEmpty ? "#fef2f2" : "white";
|
||||
// Escape user-provided strings and coerce numbers to prevent XSS
|
||||
const safeName = escapeHtml(row.name);
|
||||
const safeMedsLeft = Number(row.medsLeft) || 0;
|
||||
const safeDaysLeft = Number(row.daysLeft) || 0;
|
||||
const safeDepletionDate = row.depletionDate ? escapeHtml(String(row.depletionDate)) : "-";
|
||||
return `
|
||||
<tr style="background: ${rowBg};">
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${statusIcon} ${escapeHtml(row.name)}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap; ${isEmpty ? "color: #dc2626; font-weight: 600;" : ""}"><strong>${row.medsLeft}</strong></td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${row.daysLeft ?? 0}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${isEmpty ? "<strong>NOW</strong>" : (row.depletionDate ?? "-")}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${statusIcon} ${safeName}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap; ${isEmpty ? "color: #dc2626; font-weight: 600;" : ""}"><strong>${safeMedsLeft}</strong></td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${safeDaysLeft}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${isEmpty ? "<strong>NOW</strong>" : safeDepletionDate}</td>
|
||||
</tr>`;
|
||||
};
|
||||
|
||||
const tableRows = lowStock.map(buildTableRow).join("");
|
||||
};
|
||||
|
||||
// Build description text
|
||||
let descriptionText: string;
|
||||
if (emptyMeds.length > 0 && lowMeds.length > 0) {
|
||||
descriptionText = "The following medications need to be reordered:";
|
||||
} else if (emptyMeds.length > 0) {
|
||||
descriptionText = "The following medications are EMPTY and need to be reordered immediately:";
|
||||
} else {
|
||||
descriptionText = "The following medications are running low and need to be reordered:";
|
||||
}
|
||||
const tableRows = lowStock.map(buildTableRow).join("");
|
||||
|
||||
const html = `
|
||||
// Build description text
|
||||
let descriptionText: string;
|
||||
if (emptyMeds.length > 0 && lowMeds.length > 0) {
|
||||
descriptionText = "The following medications need to be reordered:";
|
||||
} else if (emptyMeds.length > 0) {
|
||||
descriptionText = "The following medications are EMPTY and need to be reordered immediately:";
|
||||
} else {
|
||||
descriptionText = "The following medications are running low and need to be reordered:";
|
||||
}
|
||||
|
||||
const html = `
|
||||
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;">
|
||||
<div style="background: white; border-radius: 12px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
||||
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">${emptyMeds.length > 0 ? "🚨" : "⚠️"} MedAssist-ng - Reorder Reminder</h2>
|
||||
@@ -342,120 +357,124 @@ Sent from MedAssist-ng Medication Planner`;
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Build plain text with sections
|
||||
let plainTextContent: string;
|
||||
if (emptyMeds.length > 0 && lowMeds.length > 0) {
|
||||
plainTextContent = `🚨 EMPTY (reorder immediately):
|
||||
// Build plain text with sections
|
||||
let plainTextContent: string;
|
||||
if (emptyMeds.length > 0 && lowMeds.length > 0) {
|
||||
plainTextContent = `🚨 EMPTY (reorder immediately):
|
||||
${emptyMeds.map((r) => ` • ${r.name}`).join("\n")}
|
||||
|
||||
⚠️ RUNNING LOW (reorder soon):
|
||||
${lowMeds.map((r) => ` • ${r.name}: ${r.medsLeft} pills left, ${r.daysLeft ?? 0} days remaining`).join("\n")}`;
|
||||
} else if (emptyMeds.length > 0) {
|
||||
plainTextContent = `🚨 EMPTY (reorder immediately):
|
||||
} else if (emptyMeds.length > 0) {
|
||||
plainTextContent = `🚨 EMPTY (reorder immediately):
|
||||
${emptyMeds.map((r) => ` • ${r.name}`).join("\n")}`;
|
||||
} else {
|
||||
plainTextContent = `⚠️ Running low:
|
||||
} else {
|
||||
plainTextContent = `⚠️ Running low:
|
||||
${lowMeds.map((r) => ` • ${r.name}: ${r.medsLeft} pills left, ${r.daysLeft ?? 0} days remaining, runs out ${r.depletionDate ?? "soon"}`).join("\n")}`;
|
||||
}
|
||||
}
|
||||
|
||||
const plainText = `MedAssist-ng - Reorder Reminder
|
||||
const plainText = `MedAssist-ng - Reorder Reminder
|
||||
|
||||
${plainTextContent}
|
||||
|
||||
---
|
||||
Sent from MedAssist-ng Medication Planner`;
|
||||
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtpHost,
|
||||
port: smtpPort,
|
||||
secure: smtpSecure,
|
||||
auth: {
|
||||
user: smtpUser,
|
||||
pass: smtpPass ?? "",
|
||||
},
|
||||
});
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtpHost,
|
||||
port: smtpPort,
|
||||
secure: smtpSecure,
|
||||
auth: {
|
||||
user: smtpUser,
|
||||
pass: smtpPass ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
await transporter.sendMail({
|
||||
from: smtpFrom,
|
||||
to: email,
|
||||
subject: `MedAssist-ng - ${subjectText}`,
|
||||
text: plainText,
|
||||
html,
|
||||
});
|
||||
await transporter.sendMail({
|
||||
from: smtpFrom,
|
||||
to: email,
|
||||
subject: `MedAssist-ng - ${subjectText}`,
|
||||
text: plainText,
|
||||
html,
|
||||
});
|
||||
|
||||
results.email = true;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
results.errors.push(`Email: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
results.email = true;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
results.errors.push(`Email: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send push notification if enabled
|
||||
if (notificationSettings.shoutrrrEnabled && notificationSettings.shoutrrrUrl) {
|
||||
// Get translations based on user language (default to 'en')
|
||||
const tr = getTranslations((userSettings.language as Language) || "en");
|
||||
|
||||
// Build clear title
|
||||
const titleParts: string[] = [];
|
||||
if (emptyMeds.length > 0) {
|
||||
titleParts.push(`🚨 ${emptyMeds.length} ${tr.push.empty}`);
|
||||
}
|
||||
if (lowMeds.length > 0) {
|
||||
titleParts.push(`⚠️ ${lowMeds.length} ${tr.push.low}`);
|
||||
}
|
||||
const title = `MedAssist: ${titleParts.join(", ")} - ${tr.push.reorderNow}`;
|
||||
|
||||
// Build clear message with sections
|
||||
const messageParts: string[] = [];
|
||||
if (emptyMeds.length > 0) {
|
||||
messageParts.push(`🚨 ${tr.push.emptySection}:`);
|
||||
emptyMeds.forEach(r => messageParts.push(` • ${r.name}`));
|
||||
}
|
||||
if (lowMeds.length > 0) {
|
||||
if (emptyMeds.length > 0) messageParts.push("");
|
||||
messageParts.push(`⚠️ ${tr.push.lowSection}:`);
|
||||
lowMeds.forEach(r => messageParts.push(` • ${r.name}: ${t(tr.push.pillsLeft, { count: r.medsLeft })}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}`));
|
||||
}
|
||||
const message = messageParts.join("\n");
|
||||
// Send push notification if enabled
|
||||
if (notificationSettings.shoutrrrEnabled && notificationSettings.shoutrrrUrl) {
|
||||
// Get translations based on user language (default to 'en')
|
||||
const tr = getTranslations((userSettings.language as Language) || "en");
|
||||
|
||||
try {
|
||||
const pushResult = await sendShoutrrrNotification(notificationSettings.shoutrrrUrl, title, message);
|
||||
if (pushResult.success) {
|
||||
results.push = true;
|
||||
} else {
|
||||
results.errors.push(`Push: ${pushResult.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
results.errors.push(`Push: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
// Build clear title
|
||||
const titleParts: string[] = [];
|
||||
if (emptyMeds.length > 0) {
|
||||
titleParts.push(`🚨 ${emptyMeds.length} ${tr.push.empty}`);
|
||||
}
|
||||
if (lowMeds.length > 0) {
|
||||
titleParts.push(`⚠️ ${lowMeds.length} ${tr.push.low}`);
|
||||
}
|
||||
const title = `MedAssist: ${titleParts.join(", ")} - ${tr.push.reorderNow}`;
|
||||
|
||||
// Update the reminder state to record this notification was sent
|
||||
if (results.email || results.push) {
|
||||
const channel = results.email && results.push ? "both" : results.email ? "email" : "push";
|
||||
updateReminderSentTime("stock", channel);
|
||||
|
||||
// Also update user settings in database so frontend can display the info
|
||||
await updateUserReminderSentTime(userId, "stock", channel);
|
||||
}
|
||||
// Build clear message with sections
|
||||
const messageParts: string[] = [];
|
||||
if (emptyMeds.length > 0) {
|
||||
messageParts.push(`🚨 ${tr.push.emptySection}:`);
|
||||
emptyMeds.forEach((r) => messageParts.push(` • ${r.name}`));
|
||||
}
|
||||
if (lowMeds.length > 0) {
|
||||
if (emptyMeds.length > 0) messageParts.push("");
|
||||
messageParts.push(`⚠️ ${tr.push.lowSection}:`);
|
||||
lowMeds.forEach((r) =>
|
||||
messageParts.push(
|
||||
` • ${r.name}: ${t(tr.push.pillsLeft, { count: r.medsLeft })}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}`
|
||||
)
|
||||
);
|
||||
}
|
||||
const message = messageParts.join("\n");
|
||||
|
||||
// Build response message
|
||||
const sentChannels: string[] = [];
|
||||
if (results.email) sentChannels.push("email");
|
||||
if (results.push) sentChannels.push("push");
|
||||
try {
|
||||
const pushResult = await sendShoutrrrNotification(notificationSettings.shoutrrrUrl, title, message);
|
||||
if (pushResult.success) {
|
||||
results.push = true;
|
||||
} else {
|
||||
results.errors.push(`Push: ${pushResult.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
results.errors.push(`Push: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (sentChannels.length > 0) {
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: `Reminder sent via ${sentChannels.join(" and ")}`
|
||||
});
|
||||
} else if (results.errors.length > 0) {
|
||||
return reply.status(500).send({ error: results.errors.join("; ") });
|
||||
} else {
|
||||
return reply.status(400).send({ error: "No notification channels configured" });
|
||||
}
|
||||
});
|
||||
// Update the reminder state to record this notification was sent
|
||||
if (results.email || results.push) {
|
||||
const channel = results.email && results.push ? "both" : results.email ? "email" : "push";
|
||||
updateReminderSentTime("stock", channel);
|
||||
|
||||
// Also update user settings in database so frontend can display the info
|
||||
await updateUserReminderSentTime(userId, "stock", channel);
|
||||
}
|
||||
|
||||
// Build response message
|
||||
const sentChannels: string[] = [];
|
||||
if (results.email) sentChannels.push("email");
|
||||
if (results.push) sentChannels.push("push");
|
||||
|
||||
if (sentChannels.length > 0) {
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: `Reminder sent via ${sentChannels.join(" and ")}`,
|
||||
});
|
||||
} else if (results.errors.length > 0) {
|
||||
return reply.status(500).send({ error: results.errors.join("; ") });
|
||||
} else {
|
||||
return reply.status(400).send({ error: "No notification channels configured" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+105
-98
@@ -1,124 +1,131 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
import { medications, refillHistory } from "../db/schema.js";
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
import { requireAuth, getAnonymousUserId } from "../plugins/auth.js";
|
||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
|
||||
const refillSchema = z.object({
|
||||
packsAdded: z.number().int().min(0).default(0),
|
||||
loosePillsAdded: z.number().int().min(0).default(0),
|
||||
}).refine(data => data.packsAdded > 0 || data.loosePillsAdded > 0, {
|
||||
message: "Must add at least one pack or some loose pills",
|
||||
});
|
||||
const refillSchema = z
|
||||
.object({
|
||||
packsAdded: z.number().int().min(0).default(0),
|
||||
loosePillsAdded: z.number().int().min(0).default(0),
|
||||
})
|
||||
.refine((data) => data.packsAdded > 0 || data.loosePillsAdded > 0, {
|
||||
message: "Must add at least one pack or some loose pills",
|
||||
});
|
||||
|
||||
export async function refillRoutes(app: FastifyInstance) {
|
||||
// All refill routes require auth
|
||||
app.addHook("preHandler", requireAuth);
|
||||
// All refill routes require auth
|
||||
app.addHook("preHandler", requireAuth);
|
||||
|
||||
// Helper to get user ID from request
|
||||
async function getUserId(request: any, reply: any): Promise<number> {
|
||||
if (!env.AUTH_ENABLED) {
|
||||
return getAnonymousUserId();
|
||||
}
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
if (!authUser) {
|
||||
reply.status(401).send({ error: "User not authenticated", code: "AUTH_REQUIRED" });
|
||||
throw new Error("AUTH_REQUIRED");
|
||||
}
|
||||
return authUser.id;
|
||||
}
|
||||
// Helper to get user ID from request
|
||||
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
||||
if (!env.AUTH_ENABLED) {
|
||||
return getAnonymousUserId();
|
||||
}
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
if (!authUser) {
|
||||
reply.status(401).send({ error: "User not authenticated", code: "AUTH_REQUIRED" });
|
||||
throw new Error("AUTH_REQUIRED");
|
||||
}
|
||||
return authUser.id;
|
||||
}
|
||||
|
||||
// POST /medications/:id/refill - Add stock to medication
|
||||
app.post<{ Params: { id: string } }>("/medications/:id/refill", async (req, reply) => {
|
||||
const parsed = refillSchema.safeParse(req.body);
|
||||
if (!parsed.success) return reply.status(400).send(parsed.error.format());
|
||||
// POST /medications/:id/refill - Add stock to medication
|
||||
app.post<{ Params: { id: string } }>("/medications/:id/refill", async (req, reply) => {
|
||||
const parsed = refillSchema.safeParse(req.body);
|
||||
if (!parsed.success) return reply.status(400).send(parsed.error.format());
|
||||
|
||||
const medId = Number(req.params.id);
|
||||
if (Number.isNaN(medId)) return reply.badRequest("Invalid medication id");
|
||||
const medId = Number(req.params.id);
|
||||
if (Number.isNaN(medId)) return reply.badRequest("Invalid medication id");
|
||||
|
||||
const userId = await getUserId(req, reply);
|
||||
const userId = await getUserId(req, reply);
|
||||
|
||||
// Verify ownership
|
||||
const [med] = await db.select().from(medications).where(
|
||||
and(eq(medications.id, medId), eq(medications.userId, userId))
|
||||
);
|
||||
if (!med) return reply.notFound("Medication not found");
|
||||
// Verify ownership
|
||||
const [med] = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
|
||||
if (!med) return reply.notFound("Medication not found");
|
||||
|
||||
const { packsAdded, loosePillsAdded } = parsed.data;
|
||||
const { packsAdded, loosePillsAdded } = parsed.data;
|
||||
|
||||
// Update medication stock
|
||||
const newPackCount = med.packCount + packsAdded;
|
||||
const newLooseTablets = med.looseTablets + loosePillsAdded;
|
||||
// Update medication stock
|
||||
const newPackCount = med.packCount + packsAdded;
|
||||
const newLooseTablets = med.looseTablets + loosePillsAdded;
|
||||
|
||||
await db.update(medications)
|
||||
.set({
|
||||
packCount: newPackCount,
|
||||
looseTablets: newLooseTablets,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
|
||||
await db
|
||||
.update(medications)
|
||||
.set({
|
||||
packCount: newPackCount,
|
||||
looseTablets: newLooseTablets,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
|
||||
|
||||
// Create refill history entry
|
||||
const [refill] = await db.insert(refillHistory)
|
||||
.values({
|
||||
medicationId: medId,
|
||||
userId,
|
||||
packsAdded,
|
||||
loosePillsAdded,
|
||||
})
|
||||
.returning();
|
||||
// Create refill history entry
|
||||
const [refill] = await db
|
||||
.insert(refillHistory)
|
||||
.values({
|
||||
medicationId: medId,
|
||||
userId,
|
||||
packsAdded,
|
||||
loosePillsAdded,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Calculate pills added for response
|
||||
const pillsPerPack = med.blistersPerPack * med.pillsPerBlister;
|
||||
const totalPillsAdded = (packsAdded * pillsPerPack) + loosePillsAdded;
|
||||
// Calculate pills added for response
|
||||
const pillsPerPack = med.blistersPerPack * med.pillsPerBlister;
|
||||
const totalPillsAdded = packsAdded * pillsPerPack + loosePillsAdded;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
refill: {
|
||||
id: refill.id,
|
||||
packsAdded,
|
||||
loosePillsAdded,
|
||||
totalPillsAdded,
|
||||
refillDate: refill.refillDate,
|
||||
},
|
||||
newStock: {
|
||||
packCount: newPackCount,
|
||||
looseTablets: newLooseTablets,
|
||||
totalPills: newPackCount * pillsPerPack + newLooseTablets,
|
||||
},
|
||||
};
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
refill: {
|
||||
id: refill.id,
|
||||
packsAdded,
|
||||
loosePillsAdded,
|
||||
totalPillsAdded,
|
||||
refillDate: refill.refillDate,
|
||||
},
|
||||
newStock: {
|
||||
packCount: newPackCount,
|
||||
looseTablets: newLooseTablets,
|
||||
totalPills: newPackCount * pillsPerPack + newLooseTablets,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// GET /medications/:id/refills - Get refill history for a medication
|
||||
app.get<{ Params: { id: string } }>("/medications/:id/refills", async (req, reply) => {
|
||||
const medId = Number(req.params.id);
|
||||
if (Number.isNaN(medId)) return reply.badRequest("Invalid medication id");
|
||||
// GET /medications/:id/refills - Get refill history for a medication
|
||||
app.get<{ Params: { id: string } }>("/medications/:id/refills", async (req, reply) => {
|
||||
const medId = Number(req.params.id);
|
||||
if (Number.isNaN(medId)) return reply.badRequest("Invalid medication id");
|
||||
|
||||
const userId = await getUserId(req, reply);
|
||||
const userId = await getUserId(req, reply);
|
||||
|
||||
// Verify ownership
|
||||
const [med] = await db.select().from(medications).where(
|
||||
and(eq(medications.id, medId), eq(medications.userId, userId))
|
||||
);
|
||||
if (!med) return reply.notFound("Medication not found");
|
||||
// Verify ownership
|
||||
const [med] = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
|
||||
if (!med) return reply.notFound("Medication not found");
|
||||
|
||||
// Get refill history, newest first
|
||||
const refills = await db.select()
|
||||
.from(refillHistory)
|
||||
.where(eq(refillHistory.medicationId, medId))
|
||||
.orderBy(desc(refillHistory.refillDate));
|
||||
// Get refill history, newest first
|
||||
const refills = await db
|
||||
.select()
|
||||
.from(refillHistory)
|
||||
.where(eq(refillHistory.medicationId, medId))
|
||||
.orderBy(desc(refillHistory.refillDate));
|
||||
|
||||
const pillsPerPack = med.blistersPerPack * med.pillsPerBlister;
|
||||
const pillsPerPack = med.blistersPerPack * med.pillsPerBlister;
|
||||
|
||||
return refills.map(r => ({
|
||||
id: r.id,
|
||||
packsAdded: r.packsAdded,
|
||||
loosePillsAdded: r.loosePillsAdded,
|
||||
totalPillsAdded: (r.packsAdded * pillsPerPack) + r.loosePillsAdded,
|
||||
refillDate: r.refillDate,
|
||||
}));
|
||||
});
|
||||
return refills.map((r) => ({
|
||||
id: r.id,
|
||||
packsAdded: r.packsAdded,
|
||||
loosePillsAdded: r.loosePillsAdded,
|
||||
totalPillsAdded: r.packsAdded * pillsPerPack + r.loosePillsAdded,
|
||||
refillDate: r.refillDate,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
+476
-399
@@ -1,337 +1,348 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import nodemailer from "nodemailer";
|
||||
import { db } from "../db/client.js";
|
||||
import { userSettings } from "../db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { requireAuth, getAnonymousUserId } from "../plugins/auth.js";
|
||||
import type { Language } from "../i18n/translations.js";
|
||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
import type { Language } from "../i18n/translations.js";
|
||||
|
||||
// Exported type for use in schedulers
|
||||
export type UserSettings = {
|
||||
userId: number;
|
||||
emailEnabled: boolean;
|
||||
notificationEmail: string | null;
|
||||
emailStockReminders: boolean;
|
||||
emailIntakeReminders: boolean;
|
||||
shoutrrrEnabled: boolean;
|
||||
shoutrrrUrl: string | null;
|
||||
shoutrrrStockReminders: boolean;
|
||||
shoutrrrIntakeReminders: boolean;
|
||||
reminderDaysBefore: number;
|
||||
repeatDailyReminders: boolean;
|
||||
skipRemindersForTakenDoses: boolean;
|
||||
repeatRemindersEnabled: boolean;
|
||||
reminderRepeatIntervalMinutes: number;
|
||||
maxNaggingReminders: number;
|
||||
lowStockDays: number;
|
||||
normalStockDays: number;
|
||||
highStockDays: number;
|
||||
language: Language;
|
||||
stockCalculationMode: "automatic" | "manual";
|
||||
lastAutoEmailSent: string | null;
|
||||
lastNotificationType: string | null;
|
||||
lastNotificationChannel: string | null;
|
||||
userId: number;
|
||||
emailEnabled: boolean;
|
||||
notificationEmail: string | null;
|
||||
emailStockReminders: boolean;
|
||||
emailIntakeReminders: boolean;
|
||||
shoutrrrEnabled: boolean;
|
||||
shoutrrrUrl: string | null;
|
||||
shoutrrrStockReminders: boolean;
|
||||
shoutrrrIntakeReminders: boolean;
|
||||
reminderDaysBefore: number;
|
||||
repeatDailyReminders: boolean;
|
||||
skipRemindersForTakenDoses: boolean;
|
||||
repeatRemindersEnabled: boolean;
|
||||
reminderRepeatIntervalMinutes: number;
|
||||
maxNaggingReminders: number;
|
||||
lowStockDays: number;
|
||||
normalStockDays: number;
|
||||
highStockDays: number;
|
||||
language: Language;
|
||||
stockCalculationMode: "automatic" | "manual";
|
||||
lastAutoEmailSent: string | null;
|
||||
lastNotificationType: string | null;
|
||||
lastNotificationChannel: string | null;
|
||||
lastReminderMedName: string | null;
|
||||
lastReminderTakenBy: string | null;
|
||||
};
|
||||
|
||||
type SettingsBody = {
|
||||
emailEnabled: boolean;
|
||||
notificationEmail: string;
|
||||
reminderDaysBefore: number;
|
||||
repeatDailyReminders: boolean;
|
||||
lowStockDays: number;
|
||||
normalStockDays: number;
|
||||
highStockDays: number;
|
||||
shoutrrrEnabled: boolean;
|
||||
shoutrrrUrl: string;
|
||||
emailStockReminders: boolean;
|
||||
emailIntakeReminders: boolean;
|
||||
shoutrrrStockReminders: boolean;
|
||||
shoutrrrIntakeReminders: boolean;
|
||||
skipRemindersForTakenDoses: boolean;
|
||||
repeatRemindersEnabled: boolean;
|
||||
reminderRepeatIntervalMinutes: number;
|
||||
maxNaggingReminders: number;
|
||||
language: string;
|
||||
stockCalculationMode: "automatic" | "manual";
|
||||
emailEnabled: boolean;
|
||||
notificationEmail: string;
|
||||
reminderDaysBefore: number;
|
||||
repeatDailyReminders: boolean;
|
||||
lowStockDays: number;
|
||||
normalStockDays: number;
|
||||
highStockDays: number;
|
||||
shoutrrrEnabled: boolean;
|
||||
shoutrrrUrl: string;
|
||||
emailStockReminders: boolean;
|
||||
emailIntakeReminders: boolean;
|
||||
shoutrrrStockReminders: boolean;
|
||||
shoutrrrIntakeReminders: boolean;
|
||||
skipRemindersForTakenDoses: boolean;
|
||||
repeatRemindersEnabled: boolean;
|
||||
reminderRepeatIntervalMinutes: number;
|
||||
maxNaggingReminders: number;
|
||||
language: string;
|
||||
stockCalculationMode: "automatic" | "manual";
|
||||
};
|
||||
|
||||
type TestEmailBody = {
|
||||
email: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
type TestShoutrrrBody = {
|
||||
url: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
// Helper to parse boolean env vars
|
||||
function envBool(key: string, defaultVal: boolean): boolean {
|
||||
const val = process.env[key];
|
||||
if (val === undefined) return defaultVal;
|
||||
return val === "true" || val === "1";
|
||||
const val = process.env[key];
|
||||
if (val === undefined) return defaultVal;
|
||||
return val === "true" || val === "1";
|
||||
}
|
||||
|
||||
// Helper to parse integer env vars
|
||||
function envInt(key: string, defaultVal: number): number {
|
||||
const val = process.env[key];
|
||||
if (val === undefined) return defaultVal;
|
||||
const parsed = parseInt(val, 10);
|
||||
return isNaN(parsed) ? defaultVal : parsed;
|
||||
const val = process.env[key];
|
||||
if (val === undefined) return defaultVal;
|
||||
const parsed = parseInt(val, 10);
|
||||
return Number.isNaN(parsed) ? defaultVal : parsed;
|
||||
}
|
||||
|
||||
// Default settings for new users - read from ENV with fallbacks
|
||||
function getDefaultSettings() {
|
||||
return {
|
||||
emailEnabled: envBool("DEFAULT_EMAIL_ENABLED", false),
|
||||
notificationEmail: process.env.DEFAULT_NOTIFICATION_EMAIL || null,
|
||||
emailStockReminders: envBool("DEFAULT_EMAIL_STOCK_REMINDERS", true),
|
||||
emailIntakeReminders: envBool("DEFAULT_EMAIL_INTAKE_REMINDERS", true),
|
||||
shoutrrrEnabled: envBool("DEFAULT_SHOUTRRR_ENABLED", false),
|
||||
shoutrrrUrl: process.env.DEFAULT_SHOUTRRR_URL || null,
|
||||
shoutrrrStockReminders: envBool("DEFAULT_SHOUTRRR_STOCK_REMINDERS", true),
|
||||
shoutrrrIntakeReminders: envBool("DEFAULT_SHOUTRRR_INTAKE_REMINDERS", true),
|
||||
reminderDaysBefore: envInt("REMINDER_DAYS_BEFORE", 7),
|
||||
repeatDailyReminders: envBool("DEFAULT_REPEAT_DAILY_REMINDERS", false),
|
||||
skipRemindersForTakenDoses: envBool("DEFAULT_SKIP_REMINDERS_FOR_TAKEN_DOSES", false),
|
||||
repeatRemindersEnabled: envBool("DEFAULT_REPEAT_REMINDERS_ENABLED", false),
|
||||
reminderRepeatIntervalMinutes: envInt("DEFAULT_REMINDER_REPEAT_INTERVAL_MINUTES", 30),
|
||||
maxNaggingReminders: envInt("DEFAULT_MAX_NAGGING_REMINDERS", 5),
|
||||
lowStockDays: envInt("DEFAULT_LOW_STOCK_DAYS", 30),
|
||||
normalStockDays: envInt("DEFAULT_NORMAL_STOCK_DAYS", 90),
|
||||
highStockDays: envInt("DEFAULT_HIGH_STOCK_DAYS", 180),
|
||||
language: (process.env.DEFAULT_LANGUAGE as "en" | "de") || "en",
|
||||
stockCalculationMode: (process.env.DEFAULT_STOCK_CALCULATION_MODE as "automatic" | "manual") || "automatic",
|
||||
lastAutoEmailSent: null,
|
||||
lastNotificationType: null,
|
||||
lastNotificationChannel: null,
|
||||
};
|
||||
return {
|
||||
emailEnabled: envBool("DEFAULT_EMAIL_ENABLED", false),
|
||||
notificationEmail: process.env.DEFAULT_NOTIFICATION_EMAIL || null,
|
||||
emailStockReminders: envBool("DEFAULT_EMAIL_STOCK_REMINDERS", true),
|
||||
emailIntakeReminders: envBool("DEFAULT_EMAIL_INTAKE_REMINDERS", true),
|
||||
shoutrrrEnabled: envBool("DEFAULT_SHOUTRRR_ENABLED", false),
|
||||
shoutrrrUrl: process.env.DEFAULT_SHOUTRRR_URL || null,
|
||||
shoutrrrStockReminders: envBool("DEFAULT_SHOUTRRR_STOCK_REMINDERS", true),
|
||||
shoutrrrIntakeReminders: envBool("DEFAULT_SHOUTRRR_INTAKE_REMINDERS", true),
|
||||
reminderDaysBefore: envInt("REMINDER_DAYS_BEFORE", 7),
|
||||
repeatDailyReminders: envBool("DEFAULT_REPEAT_DAILY_REMINDERS", false),
|
||||
skipRemindersForTakenDoses: envBool("DEFAULT_SKIP_REMINDERS_FOR_TAKEN_DOSES", false),
|
||||
repeatRemindersEnabled: envBool("DEFAULT_REPEAT_REMINDERS_ENABLED", false),
|
||||
reminderRepeatIntervalMinutes: envInt("DEFAULT_REMINDER_REPEAT_INTERVAL_MINUTES", 30),
|
||||
maxNaggingReminders: envInt("DEFAULT_MAX_NAGGING_REMINDERS", 5),
|
||||
lowStockDays: envInt("DEFAULT_LOW_STOCK_DAYS", 30),
|
||||
normalStockDays: envInt("DEFAULT_NORMAL_STOCK_DAYS", 90),
|
||||
highStockDays: envInt("DEFAULT_HIGH_STOCK_DAYS", 180),
|
||||
language: (process.env.DEFAULT_LANGUAGE as "en" | "de") || "en",
|
||||
stockCalculationMode: (process.env.DEFAULT_STOCK_CALCULATION_MODE as "automatic" | "manual") || "automatic",
|
||||
lastAutoEmailSent: null,
|
||||
lastNotificationType: null,
|
||||
lastNotificationChannel: null,
|
||||
lastReminderMedName: null,
|
||||
lastReminderTakenBy: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to get or create user settings
|
||||
async function getOrCreateUserSettings(userId: number) {
|
||||
let [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
|
||||
|
||||
if (!settings) {
|
||||
// Create default settings for user (using ENV defaults)
|
||||
[settings] = await db.insert(userSettings).values({
|
||||
userId,
|
||||
...getDefaultSettings(),
|
||||
}).returning();
|
||||
}
|
||||
|
||||
return settings;
|
||||
let [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
|
||||
|
||||
if (!settings) {
|
||||
// Create default settings for user (using ENV defaults)
|
||||
[settings] = await db
|
||||
.insert(userSettings)
|
||||
.values({
|
||||
userId,
|
||||
...getDefaultSettings(),
|
||||
})
|
||||
.returning();
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
// Export for use in reminder scheduler
|
||||
export async function loadUserSettings(userId: number): Promise<UserSettings> {
|
||||
const settings = await getOrCreateUserSettings(userId);
|
||||
return {
|
||||
userId: settings.userId,
|
||||
emailEnabled: settings.emailEnabled,
|
||||
notificationEmail: settings.notificationEmail,
|
||||
emailStockReminders: settings.emailStockReminders,
|
||||
emailIntakeReminders: settings.emailIntakeReminders,
|
||||
shoutrrrEnabled: settings.shoutrrrEnabled,
|
||||
shoutrrrUrl: settings.shoutrrrUrl,
|
||||
shoutrrrStockReminders: settings.shoutrrrStockReminders,
|
||||
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
||||
reminderDaysBefore: settings.reminderDaysBefore,
|
||||
repeatDailyReminders: settings.repeatDailyReminders,
|
||||
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses ?? false,
|
||||
repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false,
|
||||
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30,
|
||||
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
|
||||
lowStockDays: settings.lowStockDays,
|
||||
normalStockDays: settings.normalStockDays,
|
||||
highStockDays: settings.highStockDays,
|
||||
language: settings.language as Language,
|
||||
stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic",
|
||||
lastAutoEmailSent: settings.lastAutoEmailSent,
|
||||
lastNotificationType: settings.lastNotificationType,
|
||||
lastNotificationChannel: settings.lastNotificationChannel,
|
||||
};
|
||||
const settings = await getOrCreateUserSettings(userId);
|
||||
return {
|
||||
userId: settings.userId,
|
||||
emailEnabled: settings.emailEnabled,
|
||||
notificationEmail: settings.notificationEmail,
|
||||
emailStockReminders: settings.emailStockReminders,
|
||||
emailIntakeReminders: settings.emailIntakeReminders,
|
||||
shoutrrrEnabled: settings.shoutrrrEnabled,
|
||||
shoutrrrUrl: settings.shoutrrrUrl,
|
||||
shoutrrrStockReminders: settings.shoutrrrStockReminders,
|
||||
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
||||
reminderDaysBefore: settings.reminderDaysBefore,
|
||||
repeatDailyReminders: settings.repeatDailyReminders,
|
||||
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses ?? false,
|
||||
repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false,
|
||||
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30,
|
||||
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
|
||||
lowStockDays: settings.lowStockDays,
|
||||
normalStockDays: settings.normalStockDays,
|
||||
highStockDays: settings.highStockDays,
|
||||
language: settings.language as Language,
|
||||
stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic",
|
||||
lastAutoEmailSent: settings.lastAutoEmailSent,
|
||||
lastNotificationType: settings.lastNotificationType,
|
||||
lastNotificationChannel: settings.lastNotificationChannel,
|
||||
lastReminderMedName: settings.lastReminderMedName ?? null,
|
||||
lastReminderTakenBy: settings.lastReminderTakenBy ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// Get all users with settings for scheduler
|
||||
export async function getAllUserSettings(): Promise<UserSettings[]> {
|
||||
const allSettings = await db.select().from(userSettings);
|
||||
return allSettings.map(settings => ({
|
||||
userId: settings.userId,
|
||||
emailEnabled: settings.emailEnabled,
|
||||
notificationEmail: settings.notificationEmail,
|
||||
emailStockReminders: settings.emailStockReminders,
|
||||
emailIntakeReminders: settings.emailIntakeReminders,
|
||||
shoutrrrEnabled: settings.shoutrrrEnabled,
|
||||
shoutrrrUrl: settings.shoutrrrUrl,
|
||||
shoutrrrStockReminders: settings.shoutrrrStockReminders,
|
||||
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
||||
reminderDaysBefore: settings.reminderDaysBefore,
|
||||
repeatDailyReminders: settings.repeatDailyReminders,
|
||||
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses ?? false,
|
||||
repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false,
|
||||
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30,
|
||||
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
|
||||
lowStockDays: settings.lowStockDays,
|
||||
normalStockDays: settings.normalStockDays,
|
||||
highStockDays: settings.highStockDays,
|
||||
language: settings.language as Language,
|
||||
stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic",
|
||||
lastAutoEmailSent: settings.lastAutoEmailSent,
|
||||
lastNotificationType: settings.lastNotificationType,
|
||||
lastNotificationChannel: settings.lastNotificationChannel,
|
||||
}));
|
||||
const allSettings = await db.select().from(userSettings);
|
||||
return allSettings.map((settings) => ({
|
||||
userId: settings.userId,
|
||||
emailEnabled: settings.emailEnabled,
|
||||
notificationEmail: settings.notificationEmail,
|
||||
emailStockReminders: settings.emailStockReminders,
|
||||
emailIntakeReminders: settings.emailIntakeReminders,
|
||||
shoutrrrEnabled: settings.shoutrrrEnabled,
|
||||
shoutrrrUrl: settings.shoutrrrUrl,
|
||||
shoutrrrStockReminders: settings.shoutrrrStockReminders,
|
||||
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
||||
reminderDaysBefore: settings.reminderDaysBefore,
|
||||
repeatDailyReminders: settings.repeatDailyReminders,
|
||||
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses ?? false,
|
||||
repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false,
|
||||
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30,
|
||||
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
|
||||
lowStockDays: settings.lowStockDays,
|
||||
normalStockDays: settings.normalStockDays,
|
||||
highStockDays: settings.highStockDays,
|
||||
language: settings.language as Language,
|
||||
stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic",
|
||||
lastAutoEmailSent: settings.lastAutoEmailSent,
|
||||
lastNotificationType: settings.lastNotificationType,
|
||||
lastNotificationChannel: settings.lastNotificationChannel,
|
||||
lastReminderMedName: settings.lastReminderMedName ?? null,
|
||||
lastReminderTakenBy: settings.lastReminderTakenBy ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function settingsRoutes(app: FastifyInstance) {
|
||||
// All settings routes require auth
|
||||
app.addHook("preHandler", requireAuth);
|
||||
// All settings routes require auth
|
||||
app.addHook("preHandler", requireAuth);
|
||||
|
||||
// Helper to get user ID from request
|
||||
// Returns anonymous user ID when auth is disabled
|
||||
async function getUserId(request: any, reply: any): Promise<number> {
|
||||
// If auth is disabled, use the anonymous user
|
||||
if (!env.AUTH_ENABLED) {
|
||||
return getAnonymousUserId();
|
||||
}
|
||||
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
if (!authUser) {
|
||||
reply.status(401).send({ error: "Not authenticated" });
|
||||
throw new Error("AUTH_REQUIRED");
|
||||
}
|
||||
return authUser.id;
|
||||
}
|
||||
// Helper to get user ID from request
|
||||
// Returns anonymous user ID when auth is disabled
|
||||
async function getUserId(request: any, reply: any): Promise<number> {
|
||||
// If auth is disabled, use the anonymous user
|
||||
if (!env.AUTH_ENABLED) {
|
||||
return getAnonymousUserId();
|
||||
}
|
||||
|
||||
// Get settings for current user
|
||||
app.get("/settings", async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
if (!authUser) {
|
||||
reply.status(401).send({ error: "Not authenticated" });
|
||||
throw new Error("AUTH_REQUIRED");
|
||||
}
|
||||
return authUser.id;
|
||||
}
|
||||
|
||||
const settings = await getOrCreateUserSettings(userId);
|
||||
|
||||
return reply.send({
|
||||
// User notification settings (from DB)
|
||||
emailEnabled: settings.emailEnabled,
|
||||
notificationEmail: settings.notificationEmail ?? "",
|
||||
reminderDaysBefore: settings.reminderDaysBefore,
|
||||
repeatDailyReminders: settings.repeatDailyReminders,
|
||||
lowStockDays: settings.lowStockDays,
|
||||
normalStockDays: settings.normalStockDays,
|
||||
highStockDays: settings.highStockDays,
|
||||
shoutrrrEnabled: settings.shoutrrrEnabled,
|
||||
shoutrrrUrl: settings.shoutrrrUrl ?? "",
|
||||
emailStockReminders: settings.emailStockReminders,
|
||||
emailIntakeReminders: settings.emailIntakeReminders,
|
||||
shoutrrrStockReminders: settings.shoutrrrStockReminders,
|
||||
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
||||
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses,
|
||||
repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false,
|
||||
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30,
|
||||
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
|
||||
language: settings.language,
|
||||
stockCalculationMode: settings.stockCalculationMode ?? "automatic",
|
||||
// SMTP settings (from .env - shared/server-configured)
|
||||
smtpHost: process.env.SMTP_HOST ?? "",
|
||||
smtpPort: parseInt(process.env.SMTP_PORT ?? "587"),
|
||||
smtpUser: process.env.SMTP_USER ?? "",
|
||||
smtpFrom: process.env.SMTP_FROM ?? "",
|
||||
smtpSecure: process.env.SMTP_SECURE === "true",
|
||||
hasSmtpPassword: !!(process.env.SMTP_TOKEN || process.env.SMTP_PASS),
|
||||
// Reminder state for this user
|
||||
lastAutoEmailSent: settings.lastAutoEmailSent,
|
||||
lastNotificationType: settings.lastNotificationType,
|
||||
lastNotificationChannel: settings.lastNotificationChannel,
|
||||
// Server settings (from .env, read-only)
|
||||
expiryWarningDays: parseInt(process.env.EXPIRY_WARNING_DAYS ?? "30", 10),
|
||||
});
|
||||
});
|
||||
// Get settings for current user
|
||||
app.get("/settings", async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
|
||||
// Update settings for current user
|
||||
app.put<{ Body: SettingsBody }>("/settings", async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
const settings = await getOrCreateUserSettings(userId);
|
||||
|
||||
const body = request.body;
|
||||
|
||||
// Check if any stock reminders are configured
|
||||
const hasEmailStock = body.emailEnabled && body.emailStockReminders && body.notificationEmail;
|
||||
const hasShoutrrrStock = body.shoutrrrEnabled && body.shoutrrrStockReminders && body.shoutrrrUrl;
|
||||
const hasAnyStockReminder = hasEmailStock || hasShoutrrrStock;
|
||||
|
||||
// Disable repeatDailyReminders if no stock reminders are configured
|
||||
const repeatDailyReminders = hasAnyStockReminder ? (body.repeatDailyReminders ?? false) : false;
|
||||
return reply.send({
|
||||
// User notification settings (from DB)
|
||||
emailEnabled: settings.emailEnabled,
|
||||
notificationEmail: settings.notificationEmail ?? "",
|
||||
reminderDaysBefore: settings.reminderDaysBefore,
|
||||
repeatDailyReminders: settings.repeatDailyReminders,
|
||||
lowStockDays: settings.lowStockDays,
|
||||
normalStockDays: settings.normalStockDays,
|
||||
highStockDays: settings.highStockDays,
|
||||
shoutrrrEnabled: settings.shoutrrrEnabled,
|
||||
shoutrrrUrl: settings.shoutrrrUrl ?? "",
|
||||
emailStockReminders: settings.emailStockReminders,
|
||||
emailIntakeReminders: settings.emailIntakeReminders,
|
||||
shoutrrrStockReminders: settings.shoutrrrStockReminders,
|
||||
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
||||
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses,
|
||||
repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false,
|
||||
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30,
|
||||
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
|
||||
language: settings.language,
|
||||
stockCalculationMode: settings.stockCalculationMode ?? "automatic",
|
||||
// SMTP settings (from .env - shared/server-configured)
|
||||
smtpHost: process.env.SMTP_HOST ?? "",
|
||||
smtpPort: parseInt(process.env.SMTP_PORT ?? "587", 10),
|
||||
smtpUser: process.env.SMTP_USER ?? "",
|
||||
smtpFrom: process.env.SMTP_FROM ?? "",
|
||||
smtpSecure: process.env.SMTP_SECURE === "true",
|
||||
hasSmtpPassword: !!(process.env.SMTP_TOKEN || process.env.SMTP_PASS),
|
||||
// Reminder state for this user
|
||||
lastAutoEmailSent: settings.lastAutoEmailSent,
|
||||
lastNotificationType: settings.lastNotificationType,
|
||||
lastNotificationChannel: settings.lastNotificationChannel,
|
||||
lastReminderMedName: settings.lastReminderMedName ?? null,
|
||||
lastReminderTakenBy: settings.lastReminderTakenBy ?? null,
|
||||
// Server settings (from .env, read-only)
|
||||
expiryWarningDays: parseInt(process.env.EXPIRY_WARNING_DAYS ?? "30", 10),
|
||||
});
|
||||
});
|
||||
|
||||
// Update or insert user settings
|
||||
const existingSettings = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
|
||||
|
||||
const settingsData = {
|
||||
emailEnabled: body.emailEnabled,
|
||||
notificationEmail: body.notificationEmail || null,
|
||||
emailStockReminders: body.emailStockReminders ?? true,
|
||||
emailIntakeReminders: body.emailIntakeReminders ?? true,
|
||||
shoutrrrEnabled: body.shoutrrrEnabled ?? false,
|
||||
shoutrrrUrl: body.shoutrrrUrl || null,
|
||||
shoutrrrStockReminders: body.shoutrrrStockReminders ?? true,
|
||||
shoutrrrIntakeReminders: body.shoutrrrIntakeReminders ?? true,
|
||||
reminderDaysBefore: body.reminderDaysBefore,
|
||||
repeatDailyReminders,
|
||||
skipRemindersForTakenDoses: body.skipRemindersForTakenDoses ?? false,
|
||||
repeatRemindersEnabled: body.repeatRemindersEnabled ?? false,
|
||||
reminderRepeatIntervalMinutes: body.reminderRepeatIntervalMinutes ?? 30,
|
||||
maxNaggingReminders: body.maxNaggingReminders ?? 5,
|
||||
lowStockDays: body.lowStockDays ?? 30,
|
||||
normalStockDays: body.normalStockDays ?? 90,
|
||||
highStockDays: body.highStockDays ?? 180,
|
||||
language: body.language ?? "en",
|
||||
stockCalculationMode: body.stockCalculationMode ?? "automatic",
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
// Update settings for current user
|
||||
app.put<{ Body: SettingsBody }>("/settings", async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
|
||||
if (existingSettings.length > 0) {
|
||||
await db.update(userSettings)
|
||||
.set(settingsData)
|
||||
.where(eq(userSettings.userId, userId));
|
||||
} else {
|
||||
await db.insert(userSettings).values({
|
||||
userId: userId,
|
||||
...settingsData,
|
||||
});
|
||||
}
|
||||
const body = request.body;
|
||||
|
||||
return reply.send({ success: true });
|
||||
});
|
||||
// Check if any stock reminders are configured
|
||||
const hasEmailStock = body.emailEnabled && body.emailStockReminders && body.notificationEmail;
|
||||
const hasShoutrrrStock = body.shoutrrrEnabled && body.shoutrrrStockReminders && body.shoutrrrUrl;
|
||||
const hasAnyStockReminder = hasEmailStock || hasShoutrrrStock;
|
||||
|
||||
// Test email - use SMTP settings from process.env
|
||||
app.post<{ Body: TestEmailBody }>("/settings/test-email", async (request, reply) => {
|
||||
const { email } = request.body;
|
||||
|
||||
const smtpHost = process.env.SMTP_HOST;
|
||||
const smtpUser = process.env.SMTP_USER;
|
||||
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS;
|
||||
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587");
|
||||
const smtpSecure = process.env.SMTP_SECURE === "true";
|
||||
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
||||
// Disable repeatDailyReminders if no stock reminders are configured
|
||||
const repeatDailyReminders = hasAnyStockReminder ? (body.repeatDailyReminders ?? false) : false;
|
||||
|
||||
if (!smtpHost || !smtpUser) {
|
||||
return reply.status(400).send({ error: "SMTP not configured" });
|
||||
}
|
||||
// Update or insert user settings
|
||||
const existingSettings = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
|
||||
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtpHost,
|
||||
port: smtpPort,
|
||||
secure: smtpSecure,
|
||||
auth: {
|
||||
user: smtpUser,
|
||||
pass: smtpPass ?? "",
|
||||
},
|
||||
});
|
||||
const settingsData = {
|
||||
emailEnabled: body.emailEnabled,
|
||||
notificationEmail: body.notificationEmail || null,
|
||||
emailStockReminders: body.emailStockReminders ?? true,
|
||||
emailIntakeReminders: body.emailIntakeReminders ?? true,
|
||||
shoutrrrEnabled: body.shoutrrrEnabled ?? false,
|
||||
shoutrrrUrl: body.shoutrrrUrl || null,
|
||||
shoutrrrStockReminders: body.shoutrrrStockReminders ?? true,
|
||||
shoutrrrIntakeReminders: body.shoutrrrIntakeReminders ?? true,
|
||||
reminderDaysBefore: body.reminderDaysBefore,
|
||||
repeatDailyReminders,
|
||||
skipRemindersForTakenDoses: body.skipRemindersForTakenDoses ?? false,
|
||||
repeatRemindersEnabled: body.repeatRemindersEnabled ?? false,
|
||||
reminderRepeatIntervalMinutes: body.reminderRepeatIntervalMinutes ?? 30,
|
||||
maxNaggingReminders: body.maxNaggingReminders ?? 5,
|
||||
lowStockDays: body.lowStockDays ?? 30,
|
||||
normalStockDays: body.normalStockDays ?? 90,
|
||||
highStockDays: body.highStockDays ?? 180,
|
||||
language: body.language ?? "en",
|
||||
stockCalculationMode: body.stockCalculationMode ?? "automatic",
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
await transporter.sendMail({
|
||||
from: smtpFrom,
|
||||
to: email,
|
||||
subject: "MedAssist-ng - Test Email",
|
||||
text: "This is a test email from MedAssist-ng. If you received this, your email configuration is working correctly!",
|
||||
html: `
|
||||
if (existingSettings.length > 0) {
|
||||
await db.update(userSettings).set(settingsData).where(eq(userSettings.userId, userId));
|
||||
} else {
|
||||
await db.insert(userSettings).values({
|
||||
userId: userId,
|
||||
...settingsData,
|
||||
});
|
||||
}
|
||||
|
||||
return reply.send({ success: true });
|
||||
});
|
||||
|
||||
// Test email - use SMTP settings from process.env
|
||||
app.post<{ Body: TestEmailBody }>("/settings/test-email", async (request, reply) => {
|
||||
const { email } = request.body;
|
||||
|
||||
const smtpHost = process.env.SMTP_HOST;
|
||||
const smtpUser = process.env.SMTP_USER;
|
||||
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS;
|
||||
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
|
||||
const smtpSecure = process.env.SMTP_SECURE === "true";
|
||||
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
||||
|
||||
if (!smtpHost || !smtpUser) {
|
||||
return reply.status(400).send({ error: "SMTP not configured" });
|
||||
}
|
||||
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtpHost,
|
||||
port: smtpPort,
|
||||
secure: smtpSecure,
|
||||
auth: {
|
||||
user: smtpUser,
|
||||
pass: smtpPass ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
await transporter.sendMail({
|
||||
from: smtpFrom,
|
||||
to: email,
|
||||
subject: "MedAssist-ng - Test Email",
|
||||
text: "This is a test email from MedAssist-ng. If you received this, your email configuration is working correctly!",
|
||||
html: `
|
||||
<div style="font-family: system-ui, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h2 style="color: #2563eb;">MedAssist-ng - Test Email</h2>
|
||||
<p>This is a test email from MedAssist-ng.</p>
|
||||
@@ -340,137 +351,203 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
<p style="color: #6b7280; font-size: 14px;">Sent from MedAssist-ng Medication Planner</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
});
|
||||
|
||||
return reply.send({ success: true, message: "Test email sent successfully" });
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
return reply.status(500).send({ error: `Failed to send email: ${errorMessage}` });
|
||||
}
|
||||
});
|
||||
return reply.send({ success: true, message: "Test email sent successfully" });
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
return reply.status(500).send({ error: `Failed to send email: ${errorMessage}` });
|
||||
}
|
||||
});
|
||||
|
||||
// Test Shoutrrr/ntfy notification
|
||||
app.post<{ Body: TestShoutrrrBody }>("/settings/test-shoutrrr", async (request, reply) => {
|
||||
const { url } = request.body;
|
||||
|
||||
if (!url) {
|
||||
return reply.status(400).send({ error: "Notification URL is required" });
|
||||
}
|
||||
// Test Shoutrrr/ntfy notification
|
||||
app.post<{ Body: TestShoutrrrBody }>("/settings/test-shoutrrr", async (request, reply) => {
|
||||
const { url } = request.body;
|
||||
|
||||
try {
|
||||
const result = await sendShoutrrrNotification(url, "MedAssist-ng Test", "This is a test notification from MedAssist-ng. If you received this, your notification configuration is working correctly!");
|
||||
|
||||
if (result.success) {
|
||||
return reply.send({ success: true, message: "Test notification sent successfully" });
|
||||
} else {
|
||||
return reply.status(500).send({ error: result.error });
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
return reply.status(500).send({ error: `Failed to send notification: ${errorMessage}` });
|
||||
}
|
||||
});
|
||||
if (!url) {
|
||||
return reply.status(400).send({ error: "Notification URL is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await sendShoutrrrNotification(
|
||||
url,
|
||||
"MedAssist-ng Test",
|
||||
"This is a test notification from MedAssist-ng. If you received this, your notification configuration is working correctly!"
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
return reply.send({ success: true, message: "Test notification sent successfully" });
|
||||
} else {
|
||||
return reply.status(500).send({ error: result.error });
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
return reply.status(500).send({ error: `Failed to send notification: ${errorMessage}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Validate URL to prevent SSRF attacks
|
||||
function isAllowedNotificationUrl(urlStr: string): { allowed: boolean; error?: string } {
|
||||
try {
|
||||
// Convert ntfy:// to https:// for parsing
|
||||
const normalizedUrl = urlStr.startsWith("ntfy://")
|
||||
? urlStr.replace("ntfy://", "https://")
|
||||
: urlStr;
|
||||
|
||||
const parsed = new URL(normalizedUrl);
|
||||
|
||||
// Only allow http and https protocols
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||
return { allowed: false, error: "Only HTTP/HTTPS protocols are allowed" };
|
||||
}
|
||||
|
||||
// Block private/internal IP addresses
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
|
||||
// Block localhost
|
||||
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') {
|
||||
return { allowed: false, error: "Localhost URLs are not allowed" };
|
||||
}
|
||||
|
||||
// Block private IP ranges (basic check)
|
||||
const ipMatch = hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
||||
if (ipMatch) {
|
||||
const [, a, b] = ipMatch.map(Number);
|
||||
// 10.x.x.x, 172.16-31.x.x, 192.168.x.x, 169.254.x.x (link-local)
|
||||
if (a === 10 || a === 127 || (a === 172 && b >= 16 && b <= 31) ||
|
||||
(a === 192 && b === 168) || (a === 169 && b === 254)) {
|
||||
return { allowed: false, error: "Private IP addresses are not allowed" };
|
||||
}
|
||||
}
|
||||
|
||||
// Block common internal hostnames
|
||||
if (hostname.endsWith('.local') || hostname.endsWith('.internal') ||
|
||||
hostname.endsWith('.lan') || hostname === 'metadata.google.internal') {
|
||||
return { allowed: false, error: "Internal hostnames are not allowed" };
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
} catch {
|
||||
return { allowed: false, error: "Invalid URL format" };
|
||||
}
|
||||
// Validate and sanitize URL to prevent SSRF attacks
|
||||
// Returns a reconstructed URL from validated components to break taint tracking
|
||||
function sanitizeNotificationUrl(
|
||||
urlStr: string
|
||||
): { url: string; isNtfy: boolean; auth?: { user: string; pass: string } } | { error: string } {
|
||||
try {
|
||||
// Convert ntfy:// to https:// for parsing, track if it was ntfy
|
||||
const isNtfy = urlStr.startsWith("ntfy://");
|
||||
const normalizedUrl = isNtfy ? urlStr.replace("ntfy://", "https://") : urlStr;
|
||||
|
||||
const parsed = new URL(normalizedUrl);
|
||||
|
||||
// Only allow http and https protocols
|
||||
if (!["http:", "https:"].includes(parsed.protocol)) {
|
||||
return { error: "Only HTTP/HTTPS protocols are allowed" };
|
||||
}
|
||||
|
||||
// Block private/internal IP addresses
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
|
||||
// Block localhost
|
||||
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") {
|
||||
return { error: "Localhost URLs are not allowed" };
|
||||
}
|
||||
|
||||
// Block private IP ranges (basic check)
|
||||
const ipMatch = hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
||||
if (ipMatch) {
|
||||
const [, a, b] = ipMatch.map(Number);
|
||||
// 10.x.x.x, 172.16-31.x.x, 192.168.x.x, 169.254.x.x (link-local)
|
||||
if (
|
||||
a === 10 ||
|
||||
a === 127 ||
|
||||
(a === 172 && b >= 16 && b <= 31) ||
|
||||
(a === 192 && b === 168) ||
|
||||
(a === 169 && b === 254)
|
||||
) {
|
||||
return { error: "Private IP addresses are not allowed" };
|
||||
}
|
||||
}
|
||||
|
||||
// Block common internal hostnames
|
||||
if (
|
||||
hostname.endsWith(".local") ||
|
||||
hostname.endsWith(".internal") ||
|
||||
hostname.endsWith(".lan") ||
|
||||
hostname === "metadata.google.internal"
|
||||
) {
|
||||
return { error: "Internal hostnames are not allowed" };
|
||||
}
|
||||
|
||||
// Reconstruct URL from validated components - this breaks taint tracking
|
||||
// because we're building a new string from validated parts, not passing through user input
|
||||
const reconstructedUrl = `${parsed.protocol}//${parsed.host}${parsed.pathname}${parsed.search}`;
|
||||
|
||||
// Extract auth credentials separately for ntfy (they're in the URL but not in host)
|
||||
const auth =
|
||||
isNtfy && parsed.username && parsed.password ? { user: parsed.username, pass: parsed.password } : undefined;
|
||||
|
||||
return { url: reconstructedUrl, isNtfy, auth };
|
||||
} catch {
|
||||
return { error: "Invalid URL format" };
|
||||
}
|
||||
}
|
||||
|
||||
// Send notification via Shoutrrr-compatible URL (supports ntfy, Discord, Telegram, etc.)
|
||||
export async function sendShoutrrrNotification(urlStr: string, title: string, message: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
// Validate URL to prevent SSRF
|
||||
const validation = isAllowedNotificationUrl(urlStr);
|
||||
if (!validation.allowed) {
|
||||
return { success: false, error: validation.error };
|
||||
}
|
||||
|
||||
let targetUrl: string;
|
||||
let method = "POST";
|
||||
let headers: Record<string, string> = {};
|
||||
let body: string | undefined;
|
||||
export async function sendShoutrrrNotification(
|
||||
urlStr: string,
|
||||
title: string,
|
||||
message: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
// Validate and sanitize URL to prevent SSRF - this reconstructs the URL
|
||||
// from validated components, breaking taint tracking
|
||||
const validation = sanitizeNotificationUrl(urlStr);
|
||||
if ("error" in validation) {
|
||||
return { success: false, error: validation.error };
|
||||
}
|
||||
|
||||
// Remove emojis from title for header compatibility
|
||||
const cleanTitle = title.replace(/[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{FE00}-\u{FE0F}]|[\u{2000}-\u{206F}]|⚠|️/gu, "").trim();
|
||||
// Use ONLY the reconstructed URL from validation - never the original urlStr
|
||||
const { url: sanitizedUrl, isNtfy, auth } = validation;
|
||||
|
||||
if (urlStr.startsWith("ntfy://")) {
|
||||
const parsed = new URL(urlStr.replace("ntfy://", "https://"));
|
||||
targetUrl = `https://${parsed.host}${parsed.pathname}`;
|
||||
headers = { "Title": cleanTitle, "Tags": "pill" };
|
||||
body = message;
|
||||
|
||||
if (parsed.username && parsed.password) {
|
||||
headers["Authorization"] = "Basic " + Buffer.from(`${parsed.username}:${parsed.password}`).toString("base64");
|
||||
}
|
||||
} else if (urlStr.startsWith("https://ntfy.") || urlStr.includes("ntfy.sh") || urlStr.includes("/ntfy/")) {
|
||||
targetUrl = urlStr;
|
||||
headers = { "Title": cleanTitle, "Tags": "pill" };
|
||||
body = message;
|
||||
} else if (urlStr.startsWith("http://") || urlStr.startsWith("https://")) {
|
||||
targetUrl = urlStr;
|
||||
headers = { "Content-Type": "application/json" };
|
||||
body = JSON.stringify({ title, message, text: `${title}\n\n${message}` });
|
||||
} else {
|
||||
return { success: false, error: "Unsupported URL format. Use ntfy:// or https:// URL" };
|
||||
}
|
||||
let targetUrl: string;
|
||||
const method = "POST";
|
||||
let headers: Record<string, string> = {};
|
||||
let body: string | undefined;
|
||||
|
||||
const response = await fetch(targetUrl, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
// Remove emojis from title for header compatibility
|
||||
const cleanTitle = title
|
||||
.replace(
|
||||
/[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{FE00}-\u{FE0F}]|[\u{2000}-\u{206F}]|⚠|️/gu,
|
||||
""
|
||||
)
|
||||
.trim();
|
||||
|
||||
if (response.ok) {
|
||||
return { success: true };
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
return { success: false, error: `HTTP ${response.status}: ${errorText}` };
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
// Determine notification type based on URL hostname
|
||||
// Use JSON format only for known webhook services that require it
|
||||
// Use proper URL parsing to prevent bypass attacks (e.g., evil.com?hooks.slack.com)
|
||||
let isJsonWebhook = false;
|
||||
try {
|
||||
const parsedUrl = new URL(sanitizedUrl);
|
||||
const hostname = parsedUrl.hostname.toLowerCase();
|
||||
const pathname = parsedUrl.pathname.toLowerCase();
|
||||
|
||||
isJsonWebhook =
|
||||
// Discord webhooks
|
||||
((hostname === "discord.com" || hostname === "discordapp.com") && pathname.startsWith("/api/webhooks")) ||
|
||||
// Slack webhooks
|
||||
hostname === "hooks.slack.com" ||
|
||||
hostname.endsWith(".hooks.slack.com") ||
|
||||
// Telegram API
|
||||
hostname === "api.telegram.org" ||
|
||||
// Gotify (can be self-hosted, so check if "gotify" is in hostname)
|
||||
hostname.includes("gotify");
|
||||
} catch {
|
||||
// If URL parsing fails, default to ntfy-style
|
||||
isJsonWebhook = false;
|
||||
}
|
||||
|
||||
// Default to ntfy-style (plain text with Title header) for all other HTTP URLs
|
||||
// This works for ntfy, Apprise, and most simple push services
|
||||
if (!isJsonWebhook) {
|
||||
targetUrl = sanitizedUrl;
|
||||
headers = { Title: cleanTitle, Tags: "pill" };
|
||||
body = message;
|
||||
|
||||
// Add auth if present (extracted during sanitization)
|
||||
if (auth) {
|
||||
headers.Authorization = `Basic ${Buffer.from(`${auth.user}:${auth.pass}`).toString("base64")}`;
|
||||
}
|
||||
} else if (sanitizedUrl.startsWith("http://") || sanitizedUrl.startsWith("https://")) {
|
||||
targetUrl = sanitizedUrl;
|
||||
headers = { "Content-Type": "application/json" };
|
||||
body = JSON.stringify({ title, message, text: `${title}\n\n${message}` });
|
||||
} else {
|
||||
return { success: false, error: "Unsupported URL format. Use ntfy:// or https:// URL" };
|
||||
}
|
||||
|
||||
// SSRF protection: targetUrl is reconstructed from sanitizeNotificationUrl() which validates:
|
||||
// - Only http/https protocols allowed
|
||||
// - Blocks localhost (localhost, 127.0.0.1, ::1)
|
||||
// - Blocks private IPs (10.x.x.x, 172.16-31.x.x, 192.168.x.x, 169.254.x.x)
|
||||
// - Blocks internal hostnames (.local, .internal, .lan, metadata.google.internal)
|
||||
// - redirect: "error" prevents redirect-based bypass attacks
|
||||
// This is an intentional feature: users configure their own external notification services
|
||||
// lgtm [js/request-forgery]
|
||||
const response = await fetch(targetUrl, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
redirect: "error", // Don't follow redirects that could bypass validation
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return { success: true };
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
return { success: false, error: `HTTP ${response.status}: ${errorText}` };
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+200
-180
@@ -1,12 +1,18 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { randomBytes } from "crypto";
|
||||
import { db } from "../db/client.js";
|
||||
import { medications, shareTokens, userSettings, users } from "../db/schema.js";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { requireAuth, optionalAuth, getAnonymousUserId } from "../plugins/auth.js";
|
||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
import {
|
||||
getAllTakenByForMedication,
|
||||
parseIntakesJson,
|
||||
parseTakenByJson,
|
||||
personTakesMedication,
|
||||
} from "../utils/scheduler-utils.js";
|
||||
|
||||
// Share token validity: 1 year in milliseconds
|
||||
const SHARE_TOKEN_VALIDITY_MS = 365 * 24 * 60 * 60 * 1000;
|
||||
@@ -15,212 +21,226 @@ const SHARE_TOKEN_VALIDITY_MS = 365 * 24 * 60 * 60 * 1000;
|
||||
// Validation Schemas
|
||||
// =============================================================================
|
||||
const createShareSchema = z.object({
|
||||
takenBy: z.string().min(1, "takenBy is required"),
|
||||
scheduleDays: z.number().int().min(1).max(365).default(30),
|
||||
takenBy: z.string().min(1, "takenBy is required"),
|
||||
scheduleDays: z.number().int().min(1).max(365).default(30),
|
||||
});
|
||||
|
||||
// Helper to get user ID from request
|
||||
// Returns anonymous user ID when auth is disabled
|
||||
async function getUserId(request: any, reply: any): Promise<number> {
|
||||
// If auth is disabled, use the anonymous user
|
||||
if (!env.AUTH_ENABLED) {
|
||||
return getAnonymousUserId();
|
||||
}
|
||||
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
if (!authUser) {
|
||||
reply.status(401).send({ error: "Not authenticated" });
|
||||
throw new Error("AUTH_REQUIRED");
|
||||
}
|
||||
return authUser.id;
|
||||
}
|
||||
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
||||
// If auth is disabled, use the anonymous user
|
||||
if (!env.AUTH_ENABLED) {
|
||||
return getAnonymousUserId();
|
||||
}
|
||||
|
||||
// Helper to parse takenByJson
|
||||
function parseTakenByJson(takenByJson: string | null | undefined): string[] {
|
||||
if (!takenByJson) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(takenByJson);
|
||||
return Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string" && s.trim()) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const authUser = request.user as unknown as AuthUser | null;
|
||||
if (!authUser) {
|
||||
reply.status(401).send({ error: "Not authenticated" });
|
||||
throw new Error("AUTH_REQUIRED");
|
||||
}
|
||||
return authUser.id;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Share Routes
|
||||
// =============================================================================
|
||||
export async function shareRoutes(app: FastifyInstance) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /share/:token - PUBLIC: Get shared schedule by token
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get<{ Params: { token: string } }>("/share/:token", async (request, reply) => {
|
||||
const { token } = request.params;
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /share/:token - PUBLIC: Get shared schedule by token
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get<{ Params: { token: string } }>("/share/:token", async (request, reply) => {
|
||||
const { token } = request.params;
|
||||
|
||||
// Find share token
|
||||
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
||||
if (!share) {
|
||||
return reply.status(404).send({
|
||||
error: "Share link not found",
|
||||
code: "NOT_FOUND"
|
||||
});
|
||||
}
|
||||
// Find share token
|
||||
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
||||
if (!share) {
|
||||
return reply.status(404).send({
|
||||
error: "Share link not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if token has expired
|
||||
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
|
||||
// Get the username of the owner to show in the expired message
|
||||
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
|
||||
return reply.status(410).send({
|
||||
error: "Share link has expired",
|
||||
code: "EXPIRED",
|
||||
ownerUsername: owner?.username ?? "the owner",
|
||||
takenBy: share.takenBy,
|
||||
expiredAt: share.expiresAt.toISOString(),
|
||||
});
|
||||
}
|
||||
// Check if token has expired
|
||||
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
|
||||
// Get the username of the owner to show in the expired message
|
||||
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
|
||||
return reply.status(410).send({
|
||||
error: "Share link has expired",
|
||||
code: "EXPIRED",
|
||||
ownerUsername: owner?.username ?? "the owner",
|
||||
takenBy: share.takenBy,
|
||||
expiredAt: share.expiresAt.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Get user settings for stock thresholds
|
||||
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, share.userId));
|
||||
// Get user settings for stock thresholds
|
||||
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, share.userId));
|
||||
|
||||
// Get the username of the owner who created this share link
|
||||
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
|
||||
// Get the username of the owner who created this share link
|
||||
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
|
||||
|
||||
// Get medications for this user filtered by takenBy (search in JSON array)
|
||||
// Use SQLite JSON function to check if takenBy is in the array
|
||||
const allMeds = await db.select().from(medications).where(eq(medications.userId, share.userId));
|
||||
|
||||
// Filter medications where takenByJson array contains the share.takenBy value
|
||||
const meds = allMeds.filter((med) => {
|
||||
const takenByArray = parseTakenByJson(med.takenByJson);
|
||||
return takenByArray.includes(share.takenBy);
|
||||
});
|
||||
// Get medications for this user filtered by takenBy (search in JSON array)
|
||||
// Use SQLite JSON function to check if takenBy is in the array
|
||||
const allMeds = await db.select().from(medications).where(eq(medications.userId, share.userId));
|
||||
|
||||
// Parse blisters and build schedule data
|
||||
const medicationsWithBlisters = meds.map((med) => {
|
||||
let blisters: { usage: number; every: number; start: string }[] = [];
|
||||
try {
|
||||
const usageArr = JSON.parse(med.usageJson || "[]");
|
||||
const everyArr = JSON.parse(med.everyJson || "[]");
|
||||
const startArr = JSON.parse(med.startJson || "[]");
|
||||
blisters = usageArr.map((usage: number, i: number) => ({
|
||||
usage,
|
||||
every: everyArr[i] ?? 1,
|
||||
start: startArr[i] ?? new Date().toISOString(),
|
||||
}));
|
||||
} catch {
|
||||
blisters = [];
|
||||
}
|
||||
// Filter medications where takenBy matches either medication-level OR any intake-level takenBy
|
||||
const meds = allMeds.filter((med) => {
|
||||
const takenByArray = parseTakenByJson(med.takenByJson);
|
||||
const intakes = parseIntakesJson(
|
||||
med.intakesJson,
|
||||
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
|
||||
med.intakeRemindersEnabled ?? false
|
||||
);
|
||||
return personTakesMedication(share.takenBy, takenByArray, intakes);
|
||||
});
|
||||
|
||||
// Parse takenBy JSON array
|
||||
const takenByArray = parseTakenByJson(med.takenByJson);
|
||||
// Parse blisters and build schedule data
|
||||
const medicationsWithBlisters = meds.map((med) => {
|
||||
// Parse intakes from new format, falling back to legacy
|
||||
const intakes = parseIntakesJson(
|
||||
med.intakesJson,
|
||||
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
|
||||
med.intakeRemindersEnabled ?? false
|
||||
);
|
||||
|
||||
const totalPills = med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
|
||||
return {
|
||||
id: med.id,
|
||||
name: med.name,
|
||||
genericName: med.genericName,
|
||||
pillWeightMg: med.pillWeightMg,
|
||||
imageUrl: med.imageUrl,
|
||||
totalPills,
|
||||
packCount: med.packCount,
|
||||
blistersPerPack: med.blistersPerPack,
|
||||
looseTablets: med.looseTablets,
|
||||
pillsPerBlister: med.pillsPerBlister,
|
||||
takenBy: takenByArray,
|
||||
blisters,
|
||||
};
|
||||
});
|
||||
// Convert to legacy blisters format for backward compat
|
||||
const blisters = intakes.map((i) => ({
|
||||
usage: i.usage,
|
||||
every: i.every,
|
||||
start: i.start,
|
||||
}));
|
||||
|
||||
return {
|
||||
takenBy: share.takenBy,
|
||||
sharedBy: owner?.username ?? null,
|
||||
scheduleDays: share.scheduleDays,
|
||||
medications: medicationsWithBlisters,
|
||||
stockThresholds: {
|
||||
lowStockDays: settings?.lowStockDays ?? 30,
|
||||
},
|
||||
};
|
||||
});
|
||||
// Parse takenBy JSON array
|
||||
const takenByArray = parseTakenByJson(med.takenByJson);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /share - PROTECTED: Create a new share link
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post<{ Body: z.infer<typeof createShareSchema> }>(
|
||||
"/share",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
const totalPills =
|
||||
med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
|
||||
return {
|
||||
id: med.id,
|
||||
name: med.name,
|
||||
genericName: med.genericName,
|
||||
pillWeightMg: med.pillWeightMg,
|
||||
doseUnit: med.doseUnit ?? "mg",
|
||||
imageUrl: med.imageUrl,
|
||||
totalPills,
|
||||
packCount: med.packCount,
|
||||
blistersPerPack: med.blistersPerPack,
|
||||
looseTablets: med.looseTablets,
|
||||
pillsPerBlister: med.pillsPerBlister,
|
||||
takenBy: takenByArray,
|
||||
intakes, // New unified format with per-intake takenBy
|
||||
blisters, // Legacy format for backward compat
|
||||
dismissedUntil: med.dismissedUntil,
|
||||
updatedAt: med.updatedAt, // For filtering out doses from previous schedule configurations
|
||||
};
|
||||
});
|
||||
|
||||
const parsed = createShareSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: parsed.error.errors[0]?.message ?? "Invalid input",
|
||||
code: "VALIDATION_ERROR",
|
||||
});
|
||||
}
|
||||
return {
|
||||
takenBy: share.takenBy,
|
||||
sharedBy: owner?.username ?? null,
|
||||
scheduleDays: share.scheduleDays,
|
||||
medications: medicationsWithBlisters,
|
||||
stockThresholds: {
|
||||
lowStockDays: settings?.lowStockDays ?? 30,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { takenBy, scheduleDays } = parsed.data;
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /share - PROTECTED: Create a new share link
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post<{ Body: z.infer<typeof createShareSchema> }>(
|
||||
"/share",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
|
||||
// Check if user has medications for this takenBy (search in JSON array)
|
||||
const allMeds = await db.select().from(medications).where(eq(medications.userId, userId));
|
||||
const medsForPerson = allMeds.filter((med) => {
|
||||
const takenByArray = parseTakenByJson(med.takenByJson);
|
||||
return takenByArray.includes(takenBy);
|
||||
});
|
||||
const parsed = createShareSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: parsed.error.errors[0]?.message ?? "Invalid input",
|
||||
code: "VALIDATION_ERROR",
|
||||
});
|
||||
}
|
||||
|
||||
if (medsForPerson.length === 0) {
|
||||
return reply.status(400).send({
|
||||
error: "No medications found for this person",
|
||||
code: "NO_MEDICATIONS",
|
||||
});
|
||||
}
|
||||
const { takenBy, scheduleDays } = parsed.data;
|
||||
|
||||
// Generate unique token (8 bytes = 16 hex chars)
|
||||
const token = randomBytes(8).toString("hex");
|
||||
|
||||
// Set expiration date (1 year from now)
|
||||
const expiresAt = new Date(Date.now() + SHARE_TOKEN_VALIDITY_MS);
|
||||
// Check if user has medications for this takenBy (search in both medication-level and intake-level)
|
||||
const allMeds = await db.select().from(medications).where(eq(medications.userId, userId));
|
||||
const medsForPerson = allMeds.filter((med) => {
|
||||
const takenByArray = parseTakenByJson(med.takenByJson);
|
||||
const intakes = parseIntakesJson(
|
||||
med.intakesJson,
|
||||
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
|
||||
med.intakeRemindersEnabled ?? false
|
||||
);
|
||||
return personTakesMedication(takenBy, takenByArray, intakes);
|
||||
});
|
||||
|
||||
// Create share token
|
||||
await db.insert(shareTokens).values({
|
||||
userId: userId,
|
||||
token,
|
||||
takenBy,
|
||||
scheduleDays,
|
||||
expiresAt,
|
||||
});
|
||||
if (medsForPerson.length === 0) {
|
||||
return reply.status(400).send({
|
||||
error: "No medications found for this person",
|
||||
code: "NO_MEDICATIONS",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
shareUrl: `/share/${token}`,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
};
|
||||
}
|
||||
);
|
||||
// Generate unique token (8 bytes = 16 hex chars)
|
||||
const token = randomBytes(8).toString("hex");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /share/people - PROTECTED: Get list of unique takenBy values
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get(
|
||||
"/share/people",
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
// Set expiration date (1 year from now)
|
||||
const expiresAt = new Date(Date.now() + SHARE_TOKEN_VALIDITY_MS);
|
||||
|
||||
// Get all unique takenBy values for this user (from JSON arrays)
|
||||
const meds = await db.select({ takenByJson: medications.takenByJson })
|
||||
.from(medications)
|
||||
.where(eq(medications.userId, userId));
|
||||
// Create share token
|
||||
await db.insert(shareTokens).values({
|
||||
userId: userId,
|
||||
token,
|
||||
takenBy,
|
||||
scheduleDays,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
// Collect all unique person names from all takenByJson arrays
|
||||
const allPeople = new Set<string>();
|
||||
for (const med of meds) {
|
||||
const takenByArray = parseTakenByJson(med.takenByJson);
|
||||
for (const person of takenByArray) {
|
||||
if (person) allPeople.add(person);
|
||||
}
|
||||
}
|
||||
return {
|
||||
token,
|
||||
shareUrl: `/share/${token}`,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return { people: [...allPeople].sort() };
|
||||
}
|
||||
);
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /share/people - PROTECTED: Get list of unique takenBy values
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get("/share/people", { preHandler: requireAuth }, async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
|
||||
// Get all unique takenBy values for this user (from both medication-level and intake-level)
|
||||
const meds = await db
|
||||
.select({
|
||||
takenByJson: medications.takenByJson,
|
||||
intakesJson: medications.intakesJson,
|
||||
usageJson: medications.usageJson,
|
||||
everyJson: medications.everyJson,
|
||||
startJson: medications.startJson,
|
||||
intakeRemindersEnabled: medications.intakeRemindersEnabled,
|
||||
})
|
||||
.from(medications)
|
||||
.where(eq(medications.userId, userId));
|
||||
|
||||
// Collect all unique person names from medication-level AND intake-level takenBy
|
||||
const allPeople = new Set<string>();
|
||||
for (const med of meds) {
|
||||
const takenByArray = parseTakenByJson(med.takenByJson);
|
||||
const intakes = parseIntakesJson(
|
||||
med.intakesJson,
|
||||
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
|
||||
med.intakeRemindersEnabled ?? false
|
||||
);
|
||||
const allForMed = getAllTakenByForMedication(takenByArray, intakes);
|
||||
for (const person of allForMed) {
|
||||
if (person) allPeople.add(person);
|
||||
}
|
||||
}
|
||||
|
||||
return { people: [...allPeople].sort() };
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,27 +1,26 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import nodemailer from "nodemailer";
|
||||
import { db } from "../db/client.js";
|
||||
import { medications, userSettings } from "../db/schema.js";
|
||||
import { readFileSync, writeFileSync, existsSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
import { loadUserSettings, getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
|
||||
import { getTranslations, t, type Language } from "../i18n/translations.js";
|
||||
import { getTranslations, type Language, t } from "../i18n/translations.js";
|
||||
import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
|
||||
|
||||
// Import shared utilities
|
||||
import {
|
||||
getTimezone,
|
||||
formatInTimezone,
|
||||
getCurrentHourInTimezone,
|
||||
getTodayInTimezone,
|
||||
getNextScheduledTime,
|
||||
getMsUntilNextCheck,
|
||||
parseBlisters,
|
||||
calculateDailyUsage,
|
||||
calculateDepletionInfo,
|
||||
parseReminderState,
|
||||
createDefaultReminderState,
|
||||
type Blister,
|
||||
type ReminderState,
|
||||
type Blister,
|
||||
calculateDepletionInfo,
|
||||
createDefaultReminderState,
|
||||
formatInTimezone,
|
||||
getCurrentHourInTimezone,
|
||||
getMsUntilNextCheck,
|
||||
getNextScheduledTime,
|
||||
getTimezone,
|
||||
getTodayInTimezone,
|
||||
parseBlisters,
|
||||
parseReminderState,
|
||||
type ReminderState,
|
||||
} from "../utils/scheduler-utils.js";
|
||||
|
||||
const REMINDER_HOUR = parseInt(process.env.REMINDER_HOUR ?? "6", 10); // Default 6:00 AM local time
|
||||
@@ -29,103 +28,121 @@ const REMINDER_HOUR = parseInt(process.env.REMINDER_HOUR ?? "6", 10); // Default
|
||||
const reminderStateFile = resolve(process.cwd(), "data", "reminder-state.json");
|
||||
|
||||
function loadReminderState(): ReminderState {
|
||||
try {
|
||||
if (existsSync(reminderStateFile)) {
|
||||
return parseReminderState(readFileSync(reminderStateFile, "utf-8"));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return createDefaultReminderState();
|
||||
try {
|
||||
if (existsSync(reminderStateFile)) {
|
||||
return parseReminderState(readFileSync(reminderStateFile, "utf-8"));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return createDefaultReminderState();
|
||||
}
|
||||
|
||||
function saveReminderState(state: ReminderState): void {
|
||||
writeFileSync(reminderStateFile, JSON.stringify(state, null, 2));
|
||||
writeFileSync(reminderStateFile, JSON.stringify(state, null, 2));
|
||||
}
|
||||
|
||||
export function getReminderState(): ReminderState {
|
||||
return loadReminderState();
|
||||
return loadReminderState();
|
||||
}
|
||||
|
||||
export function updateReminderSentTime(type: "stock" | "intake" = "stock", channel: "email" | "push" | "both" = "email"): void {
|
||||
const state = loadReminderState();
|
||||
const today = getTodayInTimezone();
|
||||
saveReminderState({
|
||||
...state,
|
||||
lastAutoEmailSent: new Date().toISOString(),
|
||||
lastAutoEmailDate: today,
|
||||
lastNotificationType: type,
|
||||
lastNotificationChannel: channel,
|
||||
});
|
||||
export function updateReminderSentTime(
|
||||
type: "stock" | "intake" = "stock",
|
||||
channel: "email" | "push" | "both" = "email"
|
||||
): void {
|
||||
const state = loadReminderState();
|
||||
const today = getTodayInTimezone();
|
||||
saveReminderState({
|
||||
...state,
|
||||
lastAutoEmailSent: new Date().toISOString(),
|
||||
lastAutoEmailDate: today,
|
||||
lastNotificationType: type,
|
||||
lastNotificationChannel: channel,
|
||||
});
|
||||
}
|
||||
|
||||
// Update user settings in database when reminder is sent
|
||||
export async function updateUserReminderSentTime(
|
||||
userId: number,
|
||||
type: "stock" | "intake" = "stock",
|
||||
channel: "email" | "push" | "both" = "email"
|
||||
userId: number,
|
||||
type: "stock" | "intake" = "stock",
|
||||
channel: "email" | "push" | "both" = "email",
|
||||
medName?: string,
|
||||
takenBy?: string
|
||||
): Promise<void> {
|
||||
const now = new Date().toISOString();
|
||||
await db.update(userSettings)
|
||||
.set({
|
||||
lastAutoEmailSent: now,
|
||||
lastNotificationType: type,
|
||||
lastNotificationChannel: channel,
|
||||
})
|
||||
.where(eq(userSettings.userId, userId));
|
||||
const now = new Date().toISOString();
|
||||
await db
|
||||
.update(userSettings)
|
||||
.set({
|
||||
lastAutoEmailSent: now,
|
||||
lastNotificationType: type,
|
||||
lastNotificationChannel: channel,
|
||||
lastReminderMedName: medName ?? null,
|
||||
lastReminderTakenBy: takenBy ?? null,
|
||||
})
|
||||
.where(eq(userSettings.userId, userId));
|
||||
}
|
||||
|
||||
function parseBlistersFromRow(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] {
|
||||
return parseBlisters(row);
|
||||
return parseBlisters(row);
|
||||
}
|
||||
|
||||
type LowStockItem = {
|
||||
name: string;
|
||||
medsLeft: number;
|
||||
daysLeft: number | null;
|
||||
depletionDate: string | null;
|
||||
name: string;
|
||||
medsLeft: number;
|
||||
daysLeft: number | null;
|
||||
depletionDate: string | null;
|
||||
};
|
||||
|
||||
async function getMedicationsNeedingReminder(userId: number, reminderDaysBefore: number, language: Language): Promise<LowStockItem[]> {
|
||||
const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
|
||||
|
||||
const lowStock: LowStockItem[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const blisters = parseBlistersFromRow(row);
|
||||
const totalPills = row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets + (row.stockAdjustment ?? 0);
|
||||
const { daysLeft, depletionDate } = calculateDepletionInfo({ count: totalPills, blisters }, language);
|
||||
|
||||
// Check if medication runs out within reminderDaysBefore days
|
||||
if (daysLeft !== null && daysLeft <= reminderDaysBefore) {
|
||||
lowStock.push({
|
||||
name: row.name,
|
||||
medsLeft: totalPills,
|
||||
daysLeft,
|
||||
depletionDate,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return lowStock;
|
||||
async function getMedicationsNeedingReminder(
|
||||
userId: number,
|
||||
reminderDaysBefore: number,
|
||||
language: Language
|
||||
): Promise<LowStockItem[]> {
|
||||
const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
|
||||
|
||||
const lowStock: LowStockItem[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const blisters = parseBlistersFromRow(row);
|
||||
const totalPills =
|
||||
row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets + (row.stockAdjustment ?? 0);
|
||||
const { daysLeft, depletionDate } = calculateDepletionInfo({ count: totalPills, blisters }, language);
|
||||
|
||||
// Check if medication runs out within reminderDaysBefore days
|
||||
if (daysLeft !== null && daysLeft <= reminderDaysBefore) {
|
||||
lowStock.push({
|
||||
name: row.name,
|
||||
medsLeft: totalPills,
|
||||
daysLeft,
|
||||
depletionDate,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return lowStock;
|
||||
}
|
||||
|
||||
async function sendReminderEmail(email: string, lowStock: LowStockItem[], language: Language, isRepeatDaily: boolean = false): Promise<{ success: boolean; error?: string }> {
|
||||
const smtpHost = process.env.SMTP_HOST;
|
||||
const smtpUser = process.env.SMTP_USER;
|
||||
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence
|
||||
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587");
|
||||
const smtpSecure = process.env.SMTP_SECURE === "true";
|
||||
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
||||
async function sendReminderEmail(
|
||||
email: string,
|
||||
lowStock: LowStockItem[],
|
||||
language: Language,
|
||||
isRepeatDaily: boolean = false
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const smtpHost = process.env.SMTP_HOST;
|
||||
const smtpUser = process.env.SMTP_USER;
|
||||
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence
|
||||
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
|
||||
const smtpSecure = process.env.SMTP_SECURE === "true";
|
||||
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
||||
|
||||
if (!smtpHost || !smtpUser) {
|
||||
return { success: false, error: "SMTP not configured" };
|
||||
}
|
||||
if (!smtpHost || !smtpUser) {
|
||||
return { success: false, error: "SMTP not configured" };
|
||||
}
|
||||
|
||||
const tr = getTranslations(language);
|
||||
const tableRows = lowStock
|
||||
.map(
|
||||
(row) => `
|
||||
const tr = getTranslations(language);
|
||||
const tableRows = lowStock
|
||||
.map(
|
||||
(row) => `
|
||||
<tr>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${row.name}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;"><strong>${row.medsLeft}</strong></td>
|
||||
@@ -133,14 +150,15 @@ async function sendReminderEmail(email: string, lowStock: LowStockItem[], langua
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${row.depletionDate ?? "-"}</td>
|
||||
</tr>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
)
|
||||
.join("");
|
||||
|
||||
const alertText = lowStock.length === 1
|
||||
? tr.stockReminder.alertSingle
|
||||
: t(tr.stockReminder.alertMultiple, { count: lowStock.length });
|
||||
const alertText =
|
||||
lowStock.length === 1
|
||||
? tr.stockReminder.alertSingle
|
||||
: t(tr.stockReminder.alertMultiple, { count: lowStock.length });
|
||||
|
||||
const html = `
|
||||
const html = `
|
||||
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;">
|
||||
<div style="background: white; border-radius: 12px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
||||
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">${tr.stockReminder.title}</h2>
|
||||
@@ -177,7 +195,7 @@ async function sendReminderEmail(email: string, lowStock: LowStockItem[], langua
|
||||
</div>
|
||||
`;
|
||||
|
||||
const plainText = `${tr.stockReminder.title}
|
||||
const plainText = `${tr.stockReminder.title}
|
||||
|
||||
${tr.stockReminder.description}
|
||||
|
||||
@@ -186,204 +204,221 @@ ${lowStock.map((r) => `${r.name}: ${r.medsLeft} ${tr.common.pills}, ${r.daysLeft
|
||||
---
|
||||
${tr.stockReminder.footer}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDailyNote}` : ""}`;
|
||||
|
||||
const subjectPlural = lowStock.length === 1 ? "" : (language === "de" ? "e" : "s");
|
||||
const subject = t(tr.stockReminder.subject, { count: lowStock.length, s: subjectPlural, e: subjectPlural });
|
||||
const subjectPlural = lowStock.length === 1 ? "" : language === "de" ? "e" : "s";
|
||||
const subject = t(tr.stockReminder.subject, { count: lowStock.length, s: subjectPlural, e: subjectPlural });
|
||||
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtpHost,
|
||||
port: smtpPort,
|
||||
secure: smtpSecure,
|
||||
auth: {
|
||||
user: smtpUser,
|
||||
pass: smtpPass ?? "",
|
||||
},
|
||||
});
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtpHost,
|
||||
port: smtpPort,
|
||||
secure: smtpSecure,
|
||||
auth: {
|
||||
user: smtpUser,
|
||||
pass: smtpPass ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
await transporter.sendMail({
|
||||
from: smtpFrom,
|
||||
to: email,
|
||||
subject: `⚠️ ${subject}`,
|
||||
text: plainText,
|
||||
html,
|
||||
});
|
||||
await transporter.sendMail({
|
||||
from: smtpFrom,
|
||||
to: email,
|
||||
subject: `⚠️ ${subject}`,
|
||||
text: plainText,
|
||||
html,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAndSendReminder(logger: { info: (msg: string) => void; error: (msg: string) => void }): Promise<void> {
|
||||
// Get all user settings to iterate over each user
|
||||
const allUserSettings = await getAllUserSettings();
|
||||
|
||||
if (allUserSettings.length === 0) {
|
||||
logger.info("[Reminder] No users with settings found");
|
||||
return;
|
||||
}
|
||||
async function checkAndSendReminder(logger: {
|
||||
info: (msg: string) => void;
|
||||
error: (msg: string) => void;
|
||||
}): Promise<void> {
|
||||
// Get all user settings to iterate over each user
|
||||
const allUserSettings = await getAllUserSettings();
|
||||
|
||||
for (const userSettings of allUserSettings) {
|
||||
await checkAndSendReminderForUser(userSettings, logger);
|
||||
}
|
||||
if (allUserSettings.length === 0) {
|
||||
logger.info("[Reminder] No users with settings found");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const userSettings of allUserSettings) {
|
||||
await checkAndSendReminderForUser(userSettings, logger);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAndSendReminderForUser(
|
||||
settings: UserSettings & { userId: number },
|
||||
logger: { info: (msg: string) => void; error: (msg: string) => void }
|
||||
settings: UserSettings & { userId: number },
|
||||
logger: { info: (msg: string) => void; error: (msg: string) => void }
|
||||
): Promise<void> {
|
||||
const language = settings.language;
|
||||
const tr = getTranslations(language);
|
||||
|
||||
// Check if any stock reminder notifications are enabled (granular check)
|
||||
const emailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailStockReminders;
|
||||
const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrStockReminders;
|
||||
|
||||
if (!emailEnabled && !shoutrrrEnabled) {
|
||||
return; // No stock reminder notifications enabled for this user
|
||||
}
|
||||
const language = settings.language;
|
||||
const tr = getTranslations(language);
|
||||
|
||||
const state = loadReminderState();
|
||||
const today = getTodayInTimezone(); // YYYY-MM-DD in configured timezone
|
||||
const userStateKey = `user_${settings.userId}`;
|
||||
// Check if any stock reminder notifications are enabled (granular check)
|
||||
const emailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailStockReminders;
|
||||
const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrStockReminders;
|
||||
|
||||
// Get all medications that need a reminder for this user
|
||||
const allLowStock = await getMedicationsNeedingReminder(settings.userId, settings.reminderDaysBefore, language);
|
||||
|
||||
if (allLowStock.length === 0) {
|
||||
return; // No low stock for this user
|
||||
}
|
||||
if (!emailEnabled && !shoutrrrEnabled) {
|
||||
return; // No stock reminder notifications enabled for this user
|
||||
}
|
||||
|
||||
// Simple per-user tracking - check if we already sent today
|
||||
const userNotifiedKey = `${userStateKey}_${today}`;
|
||||
if (state.notifiedMedications.includes(userNotifiedKey) && !settings.repeatDailyReminders) {
|
||||
return; // Already notified this user today
|
||||
}
|
||||
const state = loadReminderState();
|
||||
const today = getTodayInTimezone(); // YYYY-MM-DD in configured timezone
|
||||
const userStateKey = `user_${settings.userId}`;
|
||||
|
||||
logger.info(`[Reminder] User ${settings.userId}: Sending reminder for ${allLowStock.length} medications...`);
|
||||
|
||||
let emailSuccess = false;
|
||||
let shoutrrrSuccess = false;
|
||||
|
||||
// Send email if enabled
|
||||
if (emailEnabled) {
|
||||
const result = await sendReminderEmail(settings.notificationEmail!, allLowStock, language, settings.repeatDailyReminders);
|
||||
emailSuccess = result.success;
|
||||
if (result.success) {
|
||||
logger.info(`[Reminder] User ${settings.userId}: Email sent successfully to ${settings.notificationEmail}`);
|
||||
} else {
|
||||
logger.error(`[Reminder] User ${settings.userId}: Failed to send email: ${result.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Send Shoutrrr notification if enabled
|
||||
if (shoutrrrEnabled) {
|
||||
// Separate empty from low stock medications
|
||||
const emptyMeds = allLowStock.filter(m => m.medsLeft <= 0);
|
||||
const lowMeds = allLowStock.filter(m => m.medsLeft > 0);
|
||||
|
||||
// Build clear title
|
||||
const titleParts: string[] = [];
|
||||
if (emptyMeds.length > 0) {
|
||||
titleParts.push(`🚨 ${emptyMeds.length} ${tr.push.empty || "Empty"}`);
|
||||
}
|
||||
if (lowMeds.length > 0) {
|
||||
titleParts.push(`⚠️ ${lowMeds.length} ${tr.push.low || "Low"}`);
|
||||
}
|
||||
const title = `MedAssist: ${titleParts.join(", ")} - ${tr.push.reorderNow || "Reorder Now!"}`;
|
||||
|
||||
// Build clear message with sections
|
||||
const messageParts: string[] = [];
|
||||
if (emptyMeds.length > 0) {
|
||||
messageParts.push(`🚨 ${tr.push.emptySection || "EMPTY (reorder immediately)"}:`);
|
||||
emptyMeds.forEach(m => messageParts.push(` • ${m.name}`));
|
||||
}
|
||||
if (lowMeds.length > 0) {
|
||||
if (emptyMeds.length > 0) messageParts.push("");
|
||||
messageParts.push(`⚠️ ${tr.push.lowSection || "RUNNING LOW (reorder soon)"}:`);
|
||||
lowMeds.forEach(m => messageParts.push(` • ${m.name}: ${t(tr.push.pillsLeft, { count: m.medsLeft })}, ${t(tr.push.daysLeft, { count: m.daysLeft ?? 0 })}`));
|
||||
}
|
||||
|
||||
if (settings.repeatDailyReminders) {
|
||||
messageParts.push("");
|
||||
messageParts.push(tr.push.repeatDailyNote);
|
||||
}
|
||||
|
||||
const message = messageParts.join("\n");
|
||||
|
||||
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
||||
shoutrrrSuccess = result.success;
|
||||
if (result.success) {
|
||||
logger.info(`[Reminder] User ${settings.userId}: Push notification sent successfully`);
|
||||
} else {
|
||||
logger.error(`[Reminder] User ${settings.userId}: Failed to send push notification: ${result.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Update state if any notification was sent successfully
|
||||
if (emailSuccess || shoutrrrSuccess) {
|
||||
const currentState = loadReminderState();
|
||||
const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push";
|
||||
saveReminderState({
|
||||
lastAutoEmailSent: new Date().toISOString(),
|
||||
lastAutoEmailDate: today,
|
||||
notifiedMedications: [...new Set([...currentState.notifiedMedications, userNotifiedKey])],
|
||||
nextScheduledCheck: currentState.nextScheduledCheck,
|
||||
lastNotificationType: "stock",
|
||||
lastNotificationChannel: channel,
|
||||
});
|
||||
|
||||
// Also update user settings in database so frontend can display the info
|
||||
await updateUserReminderSentTime(settings.userId, "stock", channel);
|
||||
}
|
||||
// Get all medications that need a reminder for this user
|
||||
const allLowStock = await getMedicationsNeedingReminder(settings.userId, settings.reminderDaysBefore, language);
|
||||
|
||||
if (allLowStock.length === 0) {
|
||||
return; // No low stock for this user
|
||||
}
|
||||
|
||||
// Simple per-user tracking - check if we already sent today
|
||||
const userNotifiedKey = `${userStateKey}_${today}`;
|
||||
if (state.notifiedMedications.includes(userNotifiedKey) && !settings.repeatDailyReminders) {
|
||||
return; // Already notified this user today
|
||||
}
|
||||
|
||||
logger.info(`[Reminder] User ${settings.userId}: Sending reminder for ${allLowStock.length} medications...`);
|
||||
|
||||
let emailSuccess = false;
|
||||
let shoutrrrSuccess = false;
|
||||
|
||||
// Send email if enabled
|
||||
if (emailEnabled) {
|
||||
const result = await sendReminderEmail(
|
||||
settings.notificationEmail!,
|
||||
allLowStock,
|
||||
language,
|
||||
settings.repeatDailyReminders
|
||||
);
|
||||
emailSuccess = result.success;
|
||||
if (result.success) {
|
||||
logger.info(`[Reminder] User ${settings.userId}: Email sent successfully to ${settings.notificationEmail}`);
|
||||
} else {
|
||||
logger.error(`[Reminder] User ${settings.userId}: Failed to send email: ${result.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Send Shoutrrr notification if enabled
|
||||
if (shoutrrrEnabled) {
|
||||
// Separate empty from low stock medications
|
||||
const emptyMeds = allLowStock.filter((m) => m.medsLeft <= 0);
|
||||
const lowMeds = allLowStock.filter((m) => m.medsLeft > 0);
|
||||
|
||||
// Build clear title
|
||||
const titleParts: string[] = [];
|
||||
if (emptyMeds.length > 0) {
|
||||
titleParts.push(`🚨 ${emptyMeds.length} ${tr.push.empty || "Empty"}`);
|
||||
}
|
||||
if (lowMeds.length > 0) {
|
||||
titleParts.push(`⚠️ ${lowMeds.length} ${tr.push.low || "Low"}`);
|
||||
}
|
||||
const title = `MedAssist: ${titleParts.join(", ")} - ${tr.push.reorderNow || "Reorder Now!"}`;
|
||||
|
||||
// Build clear message with sections
|
||||
const messageParts: string[] = [];
|
||||
if (emptyMeds.length > 0) {
|
||||
messageParts.push(`🚨 ${tr.push.emptySection || "EMPTY (reorder immediately)"}:`);
|
||||
emptyMeds.forEach((m) => messageParts.push(` • ${m.name}`));
|
||||
}
|
||||
if (lowMeds.length > 0) {
|
||||
if (emptyMeds.length > 0) messageParts.push("");
|
||||
messageParts.push(`⚠️ ${tr.push.lowSection || "RUNNING LOW (reorder soon)"}:`);
|
||||
lowMeds.forEach((m) =>
|
||||
messageParts.push(
|
||||
` • ${m.name}: ${t(tr.push.pillsLeft, { count: m.medsLeft })}, ${t(tr.push.daysLeft, { count: m.daysLeft ?? 0 })}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (settings.repeatDailyReminders) {
|
||||
messageParts.push("");
|
||||
messageParts.push(tr.push.repeatDailyNote);
|
||||
}
|
||||
|
||||
const message = messageParts.join("\n");
|
||||
|
||||
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
||||
shoutrrrSuccess = result.success;
|
||||
if (result.success) {
|
||||
logger.info(`[Reminder] User ${settings.userId}: Push notification sent successfully`);
|
||||
} else {
|
||||
logger.error(`[Reminder] User ${settings.userId}: Failed to send push notification: ${result.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Update state if any notification was sent successfully
|
||||
if (emailSuccess || shoutrrrSuccess) {
|
||||
const currentState = loadReminderState();
|
||||
const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push";
|
||||
saveReminderState({
|
||||
lastAutoEmailSent: new Date().toISOString(),
|
||||
lastAutoEmailDate: today,
|
||||
notifiedMedications: [...new Set([...currentState.notifiedMedications, userNotifiedKey])],
|
||||
nextScheduledCheck: currentState.nextScheduledCheck,
|
||||
lastNotificationType: "stock",
|
||||
lastNotificationChannel: channel,
|
||||
});
|
||||
|
||||
// Also update user settings in database so frontend can display the info
|
||||
// For stock reminders, show the first medication name
|
||||
const firstMed = allLowStock[0];
|
||||
const medNames = allLowStock.length > 1 ? `${firstMed.name} (+${allLowStock.length - 1})` : firstMed?.name;
|
||||
await updateUserReminderSentTime(settings.userId, "stock", channel, medNames);
|
||||
}
|
||||
}
|
||||
|
||||
let schedulerTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
function scheduleNextCheck(logger: { info: (msg: string) => void; error: (msg: string) => void }): void {
|
||||
const msUntilNext = getMsUntilNextCheck(REMINDER_HOUR);
|
||||
const nextTime = getNextScheduledTime(REMINDER_HOUR);
|
||||
|
||||
// Save next scheduled time to state
|
||||
const state = loadReminderState();
|
||||
saveReminderState({
|
||||
...state,
|
||||
nextScheduledCheck: nextTime.toISOString(),
|
||||
});
|
||||
|
||||
logger.info(`[Reminder] Next check scheduled for ${formatInTimezone(nextTime)} (${getTimezone()}) (in ${Math.round(msUntilNext / 1000 / 60)} minutes)`);
|
||||
|
||||
schedulerTimeout = setTimeout(() => {
|
||||
checkAndSendReminder(logger).catch((err) => logger.error(`[Reminder] Error: ${err}`));
|
||||
// Schedule the next check after this one completes
|
||||
scheduleNextCheck(logger);
|
||||
}, msUntilNext);
|
||||
const msUntilNext = getMsUntilNextCheck(REMINDER_HOUR);
|
||||
const nextTime = getNextScheduledTime(REMINDER_HOUR);
|
||||
|
||||
// Save next scheduled time to state
|
||||
const state = loadReminderState();
|
||||
saveReminderState({
|
||||
...state,
|
||||
nextScheduledCheck: nextTime.toISOString(),
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`[Reminder] Next check scheduled for ${formatInTimezone(nextTime)} (${getTimezone()}) (in ${Math.round(msUntilNext / 1000 / 60)} minutes)`
|
||||
);
|
||||
|
||||
schedulerTimeout = setTimeout(() => {
|
||||
checkAndSendReminder(logger).catch((err) => logger.error(`[Reminder] Error: ${err}`));
|
||||
// Schedule the next check after this one completes
|
||||
scheduleNextCheck(logger);
|
||||
}, msUntilNext);
|
||||
}
|
||||
|
||||
export function startReminderScheduler(logger: { info: (msg: string) => void; error: (msg: string) => void }): void {
|
||||
logger.info(`[Reminder] Starting reminder scheduler (timezone: ${getTimezone()})...`);
|
||||
|
||||
// Check if we need to run immediately (missed today's check)
|
||||
const state = loadReminderState();
|
||||
const today = getTodayInTimezone();
|
||||
const currentHour = getCurrentHourInTimezone();
|
||||
|
||||
// If it's past REMINDER_HOUR today in the configured timezone and we haven't checked today, run immediately
|
||||
if (currentHour >= REMINDER_HOUR && state.lastAutoEmailDate !== today) {
|
||||
logger.info("[Reminder] Missed today's check, running now...");
|
||||
checkAndSendReminder(logger).catch((err) => logger.error(`[Reminder] Error: ${err}`));
|
||||
}
|
||||
|
||||
// Schedule next check at REMINDER_HOUR
|
||||
scheduleNextCheck(logger);
|
||||
|
||||
logger.info(`[Reminder] Scheduler started - daily check at ${REMINDER_HOUR}:00 ${getTimezone()}`);
|
||||
logger.info(`[Reminder] Starting reminder scheduler (timezone: ${getTimezone()})...`);
|
||||
|
||||
// Check if we need to run immediately (missed today's check)
|
||||
const state = loadReminderState();
|
||||
const today = getTodayInTimezone();
|
||||
const currentHour = getCurrentHourInTimezone();
|
||||
|
||||
// If it's past REMINDER_HOUR today in the configured timezone and we haven't checked today, run immediately
|
||||
if (currentHour >= REMINDER_HOUR && state.lastAutoEmailDate !== today) {
|
||||
logger.info("[Reminder] Missed today's check, running now...");
|
||||
checkAndSendReminder(logger).catch((err) => logger.error(`[Reminder] Error: ${err}`));
|
||||
}
|
||||
|
||||
// Schedule next check at REMINDER_HOUR
|
||||
scheduleNextCheck(logger);
|
||||
|
||||
logger.info(`[Reminder] Scheduler started - daily check at ${REMINDER_HOUR}:00 ${getTimezone()}`);
|
||||
}
|
||||
|
||||
export function stopReminderScheduler(): void {
|
||||
if (schedulerTimeout) {
|
||||
clearTimeout(schedulerTimeout);
|
||||
schedulerTimeout = null;
|
||||
}
|
||||
if (schedulerTimeout) {
|
||||
clearTimeout(schedulerTimeout);
|
||||
schedulerTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
+684
-626
File diff suppressed because it is too large
Load Diff
+520
-534
File diff suppressed because it is too large
Load Diff
+504
-511
File diff suppressed because it is too large
Load Diff
+1798
-1805
File diff suppressed because it is too large
Load Diff
+326
-305
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
// Mock process.exit to prevent tests from exiting
|
||||
@@ -7,359 +7,380 @@ vi.spyOn(process, "exit").mockImplementation(mockExit as any);
|
||||
|
||||
// Re-create the schema from env.ts for testing
|
||||
const EnvSchema = z.object({
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
|
||||
PORT: z.string().transform((v) => parseInt(v, 10)).default("3000"),
|
||||
CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"),
|
||||
LOG_LEVEL: z.string().default("info"),
|
||||
AUTH_ENABLED: z.string().transform((v) => v === "true").default("false"),
|
||||
REGISTRATION_ENABLED: z.string().transform((v) => v === "true").default("false"),
|
||||
JWT_SECRET: z.string().min(10).optional(),
|
||||
REFRESH_SECRET: z.string().min(10).optional(),
|
||||
COOKIE_SECRET: z.string().min(10).optional(),
|
||||
ACCESS_TOKEN_TTL_MINUTES: z.string().transform((v) => parseInt(v, 10)).default("15"),
|
||||
REFRESH_TOKEN_TTL_DAYS: z.string().transform((v) => parseInt(v, 10)).default("7"),
|
||||
OIDC_ENABLED: z.string().transform((v) => v === "true").default("false"),
|
||||
OIDC_ISSUER_URL: z.string().url().optional(),
|
||||
OIDC_CLIENT_ID: z.string().optional(),
|
||||
OIDC_CLIENT_SECRET: z.string().optional(),
|
||||
OIDC_REDIRECT_URI: z.string().url().optional(),
|
||||
OIDC_SCOPES: z.string().default("openid profile email"),
|
||||
OIDC_AUTO_CREATE_USERS: z.string().transform((v) => v === "true").default("true"),
|
||||
OIDC_USERNAME_CLAIM: z.string().default("preferred_username"),
|
||||
OIDC_PROVIDER_NAME: z.string().default("SSO"),
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
|
||||
PORT: z
|
||||
.string()
|
||||
.transform((v) => parseInt(v, 10))
|
||||
.default("3000"),
|
||||
CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"),
|
||||
LOG_LEVEL: z.string().default("info"),
|
||||
AUTH_ENABLED: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.default("false"),
|
||||
REGISTRATION_ENABLED: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.default("false"),
|
||||
JWT_SECRET: z.string().min(10).optional(),
|
||||
REFRESH_SECRET: z.string().min(10).optional(),
|
||||
COOKIE_SECRET: z.string().min(10).optional(),
|
||||
ACCESS_TOKEN_TTL_MINUTES: z
|
||||
.string()
|
||||
.transform((v) => parseInt(v, 10))
|
||||
.default("15"),
|
||||
REFRESH_TOKEN_TTL_DAYS: z
|
||||
.string()
|
||||
.transform((v) => parseInt(v, 10))
|
||||
.default("7"),
|
||||
OIDC_ENABLED: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.default("false"),
|
||||
OIDC_ISSUER_URL: z.string().url().optional(),
|
||||
OIDC_CLIENT_ID: z.string().optional(),
|
||||
OIDC_CLIENT_SECRET: z.string().optional(),
|
||||
OIDC_REDIRECT_URI: z.string().url().optional(),
|
||||
OIDC_SCOPES: z.string().default("openid profile email"),
|
||||
OIDC_AUTO_CREATE_USERS: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.default("true"),
|
||||
OIDC_USERNAME_CLAIM: z.string().default("preferred_username"),
|
||||
OIDC_PROVIDER_NAME: z.string().default("SSO"),
|
||||
});
|
||||
|
||||
// Validation functions from env.ts
|
||||
function validateAuthSecrets(parsed: z.infer<typeof EnvSchema>): string[] {
|
||||
const missing: string[] = [];
|
||||
if (parsed.AUTH_ENABLED) {
|
||||
if (!parsed.JWT_SECRET) missing.push("JWT_SECRET");
|
||||
if (!parsed.REFRESH_SECRET) missing.push("REFRESH_SECRET");
|
||||
if (!parsed.COOKIE_SECRET) missing.push("COOKIE_SECRET");
|
||||
}
|
||||
return missing;
|
||||
const missing: string[] = [];
|
||||
if (parsed.AUTH_ENABLED) {
|
||||
if (!parsed.JWT_SECRET) missing.push("JWT_SECRET");
|
||||
if (!parsed.REFRESH_SECRET) missing.push("REFRESH_SECRET");
|
||||
if (!parsed.COOKIE_SECRET) missing.push("COOKIE_SECRET");
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
function validateOidcConfig(parsed: z.infer<typeof EnvSchema>): string[] {
|
||||
const missing: string[] = [];
|
||||
if (parsed.OIDC_ENABLED) {
|
||||
if (!parsed.OIDC_ISSUER_URL) missing.push("OIDC_ISSUER_URL");
|
||||
if (!parsed.OIDC_CLIENT_ID) missing.push("OIDC_CLIENT_ID");
|
||||
if (!parsed.OIDC_CLIENT_SECRET) missing.push("OIDC_CLIENT_SECRET");
|
||||
if (!parsed.OIDC_REDIRECT_URI) missing.push("OIDC_REDIRECT_URI");
|
||||
}
|
||||
return missing;
|
||||
const missing: string[] = [];
|
||||
if (parsed.OIDC_ENABLED) {
|
||||
if (!parsed.OIDC_ISSUER_URL) missing.push("OIDC_ISSUER_URL");
|
||||
if (!parsed.OIDC_CLIENT_ID) missing.push("OIDC_CLIENT_ID");
|
||||
if (!parsed.OIDC_CLIENT_SECRET) missing.push("OIDC_CLIENT_SECRET");
|
||||
if (!parsed.OIDC_REDIRECT_URI) missing.push("OIDC_REDIRECT_URI");
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
describe("EnvSchema", () => {
|
||||
describe("default values", () => {
|
||||
it("should use default values when env vars are empty", () => {
|
||||
const result = EnvSchema.parse({});
|
||||
|
||||
expect(result.NODE_ENV).toBe("production");
|
||||
expect(result.PORT).toBe(3000);
|
||||
expect(result.CORS_ORIGINS).toBe("http://localhost:5173,http://localhost:4173");
|
||||
expect(result.LOG_LEVEL).toBe("info");
|
||||
expect(result.AUTH_ENABLED).toBe(false);
|
||||
expect(result.REGISTRATION_ENABLED).toBe(false);
|
||||
expect(result.ACCESS_TOKEN_TTL_MINUTES).toBe(15);
|
||||
expect(result.REFRESH_TOKEN_TTL_DAYS).toBe(7);
|
||||
expect(result.OIDC_ENABLED).toBe(false);
|
||||
expect(result.OIDC_SCOPES).toBe("openid profile email");
|
||||
expect(result.OIDC_AUTO_CREATE_USERS).toBe(true);
|
||||
expect(result.OIDC_USERNAME_CLAIM).toBe("preferred_username");
|
||||
expect(result.OIDC_PROVIDER_NAME).toBe("SSO");
|
||||
});
|
||||
});
|
||||
describe("default values", () => {
|
||||
it("should use default values when env vars are empty", () => {
|
||||
const result = EnvSchema.parse({});
|
||||
|
||||
describe("NODE_ENV validation", () => {
|
||||
it("should accept development", () => {
|
||||
const result = EnvSchema.parse({ NODE_ENV: "development" });
|
||||
expect(result.NODE_ENV).toBe("development");
|
||||
});
|
||||
expect(result.NODE_ENV).toBe("production");
|
||||
expect(result.PORT).toBe(3000);
|
||||
expect(result.CORS_ORIGINS).toBe("http://localhost:5173,http://localhost:4173");
|
||||
expect(result.LOG_LEVEL).toBe("info");
|
||||
expect(result.AUTH_ENABLED).toBe(false);
|
||||
expect(result.REGISTRATION_ENABLED).toBe(false);
|
||||
expect(result.ACCESS_TOKEN_TTL_MINUTES).toBe(15);
|
||||
expect(result.REFRESH_TOKEN_TTL_DAYS).toBe(7);
|
||||
expect(result.OIDC_ENABLED).toBe(false);
|
||||
expect(result.OIDC_SCOPES).toBe("openid profile email");
|
||||
expect(result.OIDC_AUTO_CREATE_USERS).toBe(true);
|
||||
expect(result.OIDC_USERNAME_CLAIM).toBe("preferred_username");
|
||||
expect(result.OIDC_PROVIDER_NAME).toBe("SSO");
|
||||
});
|
||||
});
|
||||
|
||||
it("should accept production", () => {
|
||||
const result = EnvSchema.parse({ NODE_ENV: "production" });
|
||||
expect(result.NODE_ENV).toBe("production");
|
||||
});
|
||||
describe("NODE_ENV validation", () => {
|
||||
it("should accept development", () => {
|
||||
const result = EnvSchema.parse({ NODE_ENV: "development" });
|
||||
expect(result.NODE_ENV).toBe("development");
|
||||
});
|
||||
|
||||
it("should accept test", () => {
|
||||
const result = EnvSchema.parse({ NODE_ENV: "test" });
|
||||
expect(result.NODE_ENV).toBe("test");
|
||||
});
|
||||
it("should accept production", () => {
|
||||
const result = EnvSchema.parse({ NODE_ENV: "production" });
|
||||
expect(result.NODE_ENV).toBe("production");
|
||||
});
|
||||
|
||||
it("should reject invalid NODE_ENV values", () => {
|
||||
expect(() => EnvSchema.parse({ NODE_ENV: "staging" })).toThrow();
|
||||
expect(() => EnvSchema.parse({ NODE_ENV: "invalid" })).toThrow();
|
||||
});
|
||||
});
|
||||
it("should accept test", () => {
|
||||
const result = EnvSchema.parse({ NODE_ENV: "test" });
|
||||
expect(result.NODE_ENV).toBe("test");
|
||||
});
|
||||
|
||||
describe("PORT transformation", () => {
|
||||
it("should transform string PORT to number", () => {
|
||||
const result = EnvSchema.parse({ PORT: "8080" });
|
||||
expect(result.PORT).toBe(8080);
|
||||
});
|
||||
it("should reject invalid NODE_ENV values", () => {
|
||||
expect(() => EnvSchema.parse({ NODE_ENV: "staging" })).toThrow();
|
||||
expect(() => EnvSchema.parse({ NODE_ENV: "invalid" })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it("should use default port when not provided", () => {
|
||||
const result = EnvSchema.parse({});
|
||||
expect(result.PORT).toBe(3000);
|
||||
});
|
||||
});
|
||||
describe("PORT transformation", () => {
|
||||
it("should transform string PORT to number", () => {
|
||||
const result = EnvSchema.parse({ PORT: "8080" });
|
||||
expect(result.PORT).toBe(8080);
|
||||
});
|
||||
|
||||
describe("boolean transformations", () => {
|
||||
it("should transform AUTH_ENABLED=true to boolean true", () => {
|
||||
const result = EnvSchema.parse({ AUTH_ENABLED: "true" });
|
||||
expect(result.AUTH_ENABLED).toBe(true);
|
||||
});
|
||||
it("should use default port when not provided", () => {
|
||||
const result = EnvSchema.parse({});
|
||||
expect(result.PORT).toBe(3000);
|
||||
});
|
||||
});
|
||||
|
||||
it("should transform AUTH_ENABLED=false to boolean false", () => {
|
||||
const result = EnvSchema.parse({ AUTH_ENABLED: "false" });
|
||||
expect(result.AUTH_ENABLED).toBe(false);
|
||||
});
|
||||
describe("boolean transformations", () => {
|
||||
it("should transform AUTH_ENABLED=true to boolean true", () => {
|
||||
const result = EnvSchema.parse({ AUTH_ENABLED: "true" });
|
||||
expect(result.AUTH_ENABLED).toBe(true);
|
||||
});
|
||||
|
||||
it("should treat non-true string as false", () => {
|
||||
const result = EnvSchema.parse({ AUTH_ENABLED: "yes" });
|
||||
expect(result.AUTH_ENABLED).toBe(false);
|
||||
});
|
||||
it("should transform AUTH_ENABLED=false to boolean false", () => {
|
||||
const result = EnvSchema.parse({ AUTH_ENABLED: "false" });
|
||||
expect(result.AUTH_ENABLED).toBe(false);
|
||||
});
|
||||
|
||||
it("should transform REGISTRATION_ENABLED correctly", () => {
|
||||
expect(EnvSchema.parse({ REGISTRATION_ENABLED: "true" }).REGISTRATION_ENABLED).toBe(true);
|
||||
expect(EnvSchema.parse({ REGISTRATION_ENABLED: "false" }).REGISTRATION_ENABLED).toBe(false);
|
||||
});
|
||||
it("should treat non-true string as false", () => {
|
||||
const result = EnvSchema.parse({ AUTH_ENABLED: "yes" });
|
||||
expect(result.AUTH_ENABLED).toBe(false);
|
||||
});
|
||||
|
||||
it("should transform OIDC_ENABLED correctly", () => {
|
||||
expect(EnvSchema.parse({ OIDC_ENABLED: "true" }).OIDC_ENABLED).toBe(true);
|
||||
expect(EnvSchema.parse({ OIDC_ENABLED: "false" }).OIDC_ENABLED).toBe(false);
|
||||
});
|
||||
it("should transform REGISTRATION_ENABLED correctly", () => {
|
||||
expect(EnvSchema.parse({ REGISTRATION_ENABLED: "true" }).REGISTRATION_ENABLED).toBe(true);
|
||||
expect(EnvSchema.parse({ REGISTRATION_ENABLED: "false" }).REGISTRATION_ENABLED).toBe(false);
|
||||
});
|
||||
|
||||
it("should transform OIDC_AUTO_CREATE_USERS correctly", () => {
|
||||
expect(EnvSchema.parse({ OIDC_AUTO_CREATE_USERS: "true" }).OIDC_AUTO_CREATE_USERS).toBe(true);
|
||||
expect(EnvSchema.parse({ OIDC_AUTO_CREATE_USERS: "false" }).OIDC_AUTO_CREATE_USERS).toBe(false);
|
||||
});
|
||||
});
|
||||
it("should transform OIDC_ENABLED correctly", () => {
|
||||
expect(EnvSchema.parse({ OIDC_ENABLED: "true" }).OIDC_ENABLED).toBe(true);
|
||||
expect(EnvSchema.parse({ OIDC_ENABLED: "false" }).OIDC_ENABLED).toBe(false);
|
||||
});
|
||||
|
||||
describe("JWT secret validation", () => {
|
||||
it("should accept JWT_SECRET with 10+ characters", () => {
|
||||
const result = EnvSchema.parse({ JWT_SECRET: "1234567890" });
|
||||
expect(result.JWT_SECRET).toBe("1234567890");
|
||||
});
|
||||
it("should transform OIDC_AUTO_CREATE_USERS correctly", () => {
|
||||
expect(EnvSchema.parse({ OIDC_AUTO_CREATE_USERS: "true" }).OIDC_AUTO_CREATE_USERS).toBe(true);
|
||||
expect(EnvSchema.parse({ OIDC_AUTO_CREATE_USERS: "false" }).OIDC_AUTO_CREATE_USERS).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("should reject JWT_SECRET with less than 10 characters", () => {
|
||||
expect(() => EnvSchema.parse({ JWT_SECRET: "123456789" })).toThrow();
|
||||
});
|
||||
describe("JWT secret validation", () => {
|
||||
it("should accept JWT_SECRET with 10+ characters", () => {
|
||||
const result = EnvSchema.parse({ JWT_SECRET: "1234567890" });
|
||||
expect(result.JWT_SECRET).toBe("1234567890");
|
||||
});
|
||||
|
||||
it("should allow optional JWT_SECRET", () => {
|
||||
const result = EnvSchema.parse({});
|
||||
expect(result.JWT_SECRET).toBeUndefined();
|
||||
});
|
||||
});
|
||||
it("should reject JWT_SECRET with less than 10 characters", () => {
|
||||
expect(() => EnvSchema.parse({ JWT_SECRET: "123456789" })).toThrow();
|
||||
});
|
||||
|
||||
describe("TTL transformations", () => {
|
||||
it("should transform ACCESS_TOKEN_TTL_MINUTES to number", () => {
|
||||
const result = EnvSchema.parse({ ACCESS_TOKEN_TTL_MINUTES: "30" });
|
||||
expect(result.ACCESS_TOKEN_TTL_MINUTES).toBe(30);
|
||||
});
|
||||
it("should allow optional JWT_SECRET", () => {
|
||||
const result = EnvSchema.parse({});
|
||||
expect(result.JWT_SECRET).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should transform REFRESH_TOKEN_TTL_DAYS to number", () => {
|
||||
const result = EnvSchema.parse({ REFRESH_TOKEN_TTL_DAYS: "14" });
|
||||
expect(result.REFRESH_TOKEN_TTL_DAYS).toBe(14);
|
||||
});
|
||||
});
|
||||
describe("TTL transformations", () => {
|
||||
it("should transform ACCESS_TOKEN_TTL_MINUTES to number", () => {
|
||||
const result = EnvSchema.parse({ ACCESS_TOKEN_TTL_MINUTES: "30" });
|
||||
expect(result.ACCESS_TOKEN_TTL_MINUTES).toBe(30);
|
||||
});
|
||||
|
||||
describe("OIDC URL validation", () => {
|
||||
it("should accept valid OIDC_ISSUER_URL", () => {
|
||||
const result = EnvSchema.parse({ OIDC_ISSUER_URL: "https://auth.example.com" });
|
||||
expect(result.OIDC_ISSUER_URL).toBe("https://auth.example.com");
|
||||
});
|
||||
it("should transform REFRESH_TOKEN_TTL_DAYS to number", () => {
|
||||
const result = EnvSchema.parse({ REFRESH_TOKEN_TTL_DAYS: "14" });
|
||||
expect(result.REFRESH_TOKEN_TTL_DAYS).toBe(14);
|
||||
});
|
||||
});
|
||||
|
||||
it("should reject invalid OIDC_ISSUER_URL", () => {
|
||||
expect(() => EnvSchema.parse({ OIDC_ISSUER_URL: "not-a-url" })).toThrow();
|
||||
});
|
||||
describe("OIDC URL validation", () => {
|
||||
it("should accept valid OIDC_ISSUER_URL", () => {
|
||||
const result = EnvSchema.parse({ OIDC_ISSUER_URL: "https://auth.example.com" });
|
||||
expect(result.OIDC_ISSUER_URL).toBe("https://auth.example.com");
|
||||
});
|
||||
|
||||
it("should accept valid OIDC_REDIRECT_URI", () => {
|
||||
const result = EnvSchema.parse({ OIDC_REDIRECT_URI: "https://app.example.com/callback" });
|
||||
expect(result.OIDC_REDIRECT_URI).toBe("https://app.example.com/callback");
|
||||
});
|
||||
it("should reject invalid OIDC_ISSUER_URL", () => {
|
||||
expect(() => EnvSchema.parse({ OIDC_ISSUER_URL: "not-a-url" })).toThrow();
|
||||
});
|
||||
|
||||
it("should reject invalid OIDC_REDIRECT_URI", () => {
|
||||
expect(() => EnvSchema.parse({ OIDC_REDIRECT_URI: "invalid" })).toThrow();
|
||||
});
|
||||
});
|
||||
it("should accept valid OIDC_REDIRECT_URI", () => {
|
||||
const result = EnvSchema.parse({ OIDC_REDIRECT_URI: "https://app.example.com/callback" });
|
||||
expect(result.OIDC_REDIRECT_URI).toBe("https://app.example.com/callback");
|
||||
});
|
||||
|
||||
describe("CORS_ORIGINS parsing", () => {
|
||||
it("should accept comma-separated origins", () => {
|
||||
const result = EnvSchema.parse({ CORS_ORIGINS: "http://a.com,http://b.com" });
|
||||
expect(result.CORS_ORIGINS).toBe("http://a.com,http://b.com");
|
||||
});
|
||||
it("should reject invalid OIDC_REDIRECT_URI", () => {
|
||||
expect(() => EnvSchema.parse({ OIDC_REDIRECT_URI: "invalid" })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it("should accept single origin", () => {
|
||||
const result = EnvSchema.parse({ CORS_ORIGINS: "http://localhost:3000" });
|
||||
expect(result.CORS_ORIGINS).toBe("http://localhost:3000");
|
||||
});
|
||||
});
|
||||
describe("CORS_ORIGINS parsing", () => {
|
||||
it("should accept comma-separated origins", () => {
|
||||
const result = EnvSchema.parse({ CORS_ORIGINS: "http://a.com,http://b.com" });
|
||||
expect(result.CORS_ORIGINS).toBe("http://a.com,http://b.com");
|
||||
});
|
||||
|
||||
it("should accept single origin", () => {
|
||||
const result = EnvSchema.parse({ CORS_ORIGINS: "http://localhost:3000" });
|
||||
expect(result.CORS_ORIGINS).toBe("http://localhost:3000");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Auth validation", () => {
|
||||
it("should require secrets when AUTH_ENABLED=true", () => {
|
||||
const parsed = EnvSchema.parse({ AUTH_ENABLED: "true" });
|
||||
const missing = validateAuthSecrets(parsed);
|
||||
expect(missing).toContain("JWT_SECRET");
|
||||
expect(missing).toContain("REFRESH_SECRET");
|
||||
expect(missing).toContain("COOKIE_SECRET");
|
||||
});
|
||||
it("should require secrets when AUTH_ENABLED=true", () => {
|
||||
const parsed = EnvSchema.parse({ AUTH_ENABLED: "true" });
|
||||
const missing = validateAuthSecrets(parsed);
|
||||
expect(missing).toContain("JWT_SECRET");
|
||||
expect(missing).toContain("REFRESH_SECRET");
|
||||
expect(missing).toContain("COOKIE_SECRET");
|
||||
});
|
||||
|
||||
it("should not require secrets when AUTH_ENABLED=false", () => {
|
||||
const parsed = EnvSchema.parse({ AUTH_ENABLED: "false" });
|
||||
const missing = validateAuthSecrets(parsed);
|
||||
expect(missing).toHaveLength(0);
|
||||
});
|
||||
it("should not require secrets when AUTH_ENABLED=false", () => {
|
||||
const parsed = EnvSchema.parse({ AUTH_ENABLED: "false" });
|
||||
const missing = validateAuthSecrets(parsed);
|
||||
expect(missing).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should pass validation with all secrets provided", () => {
|
||||
const parsed = EnvSchema.parse({
|
||||
AUTH_ENABLED: "true",
|
||||
JWT_SECRET: "super-secret-jwt-key-12345",
|
||||
REFRESH_SECRET: "super-secret-refresh-key-12345",
|
||||
COOKIE_SECRET: "super-secret-cookie-key-12345",
|
||||
});
|
||||
const missing = validateAuthSecrets(parsed);
|
||||
expect(missing).toHaveLength(0);
|
||||
});
|
||||
it("should pass validation with all secrets provided", () => {
|
||||
const parsed = EnvSchema.parse({
|
||||
AUTH_ENABLED: "true",
|
||||
JWT_SECRET: "super-secret-jwt-key-12345",
|
||||
REFRESH_SECRET: "super-secret-refresh-key-12345",
|
||||
COOKIE_SECRET: "super-secret-cookie-key-12345",
|
||||
});
|
||||
const missing = validateAuthSecrets(parsed);
|
||||
expect(missing).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should identify which specific secrets are missing", () => {
|
||||
const parsed = EnvSchema.parse({
|
||||
AUTH_ENABLED: "true",
|
||||
JWT_SECRET: "super-secret-jwt-key-12345",
|
||||
// REFRESH_SECRET missing
|
||||
COOKIE_SECRET: "super-secret-cookie-key-12345",
|
||||
});
|
||||
const missing = validateAuthSecrets(parsed);
|
||||
expect(missing).toHaveLength(1);
|
||||
expect(missing).toContain("REFRESH_SECRET");
|
||||
});
|
||||
it("should identify which specific secrets are missing", () => {
|
||||
const parsed = EnvSchema.parse({
|
||||
AUTH_ENABLED: "true",
|
||||
JWT_SECRET: "super-secret-jwt-key-12345",
|
||||
// REFRESH_SECRET missing
|
||||
COOKIE_SECRET: "super-secret-cookie-key-12345",
|
||||
});
|
||||
const missing = validateAuthSecrets(parsed);
|
||||
expect(missing).toHaveLength(1);
|
||||
expect(missing).toContain("REFRESH_SECRET");
|
||||
});
|
||||
});
|
||||
|
||||
describe("OIDC validation", () => {
|
||||
it("should require all OIDC settings when OIDC_ENABLED=true", () => {
|
||||
const parsed = EnvSchema.parse({ OIDC_ENABLED: "true" });
|
||||
const missing = validateOidcConfig(parsed);
|
||||
expect(missing).toContain("OIDC_ISSUER_URL");
|
||||
expect(missing).toContain("OIDC_CLIENT_ID");
|
||||
expect(missing).toContain("OIDC_CLIENT_SECRET");
|
||||
expect(missing).toContain("OIDC_REDIRECT_URI");
|
||||
});
|
||||
it("should require all OIDC settings when OIDC_ENABLED=true", () => {
|
||||
const parsed = EnvSchema.parse({ OIDC_ENABLED: "true" });
|
||||
const missing = validateOidcConfig(parsed);
|
||||
expect(missing).toContain("OIDC_ISSUER_URL");
|
||||
expect(missing).toContain("OIDC_CLIENT_ID");
|
||||
expect(missing).toContain("OIDC_CLIENT_SECRET");
|
||||
expect(missing).toContain("OIDC_REDIRECT_URI");
|
||||
});
|
||||
|
||||
it("should not require OIDC settings when OIDC_ENABLED=false", () => {
|
||||
const parsed = EnvSchema.parse({ OIDC_ENABLED: "false" });
|
||||
const missing = validateOidcConfig(parsed);
|
||||
expect(missing).toHaveLength(0);
|
||||
});
|
||||
it("should not require OIDC settings when OIDC_ENABLED=false", () => {
|
||||
const parsed = EnvSchema.parse({ OIDC_ENABLED: "false" });
|
||||
const missing = validateOidcConfig(parsed);
|
||||
expect(missing).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should pass validation with all OIDC settings provided", () => {
|
||||
const parsed = EnvSchema.parse({
|
||||
OIDC_ENABLED: "true",
|
||||
OIDC_ISSUER_URL: "https://auth.example.com",
|
||||
OIDC_CLIENT_ID: "my-client-id",
|
||||
OIDC_CLIENT_SECRET: "my-client-secret",
|
||||
OIDC_REDIRECT_URI: "https://app.example.com/callback",
|
||||
});
|
||||
const missing = validateOidcConfig(parsed);
|
||||
expect(missing).toHaveLength(0);
|
||||
});
|
||||
it("should pass validation with all OIDC settings provided", () => {
|
||||
const parsed = EnvSchema.parse({
|
||||
OIDC_ENABLED: "true",
|
||||
OIDC_ISSUER_URL: "https://auth.example.com",
|
||||
OIDC_CLIENT_ID: "my-client-id",
|
||||
OIDC_CLIENT_SECRET: "my-client-secret",
|
||||
OIDC_REDIRECT_URI: "https://app.example.com/callback",
|
||||
});
|
||||
const missing = validateOidcConfig(parsed);
|
||||
expect(missing).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should identify which specific OIDC settings are missing", () => {
|
||||
const parsed = EnvSchema.parse({
|
||||
OIDC_ENABLED: "true",
|
||||
OIDC_ISSUER_URL: "https://auth.example.com",
|
||||
OIDC_CLIENT_ID: "my-client-id",
|
||||
// OIDC_CLIENT_SECRET missing
|
||||
// OIDC_REDIRECT_URI missing
|
||||
});
|
||||
const missing = validateOidcConfig(parsed);
|
||||
expect(missing).toHaveLength(2);
|
||||
expect(missing).toContain("OIDC_CLIENT_SECRET");
|
||||
expect(missing).toContain("OIDC_REDIRECT_URI");
|
||||
});
|
||||
it("should identify which specific OIDC settings are missing", () => {
|
||||
const parsed = EnvSchema.parse({
|
||||
OIDC_ENABLED: "true",
|
||||
OIDC_ISSUER_URL: "https://auth.example.com",
|
||||
OIDC_CLIENT_ID: "my-client-id",
|
||||
// OIDC_CLIENT_SECRET missing
|
||||
// OIDC_REDIRECT_URI missing
|
||||
});
|
||||
const missing = validateOidcConfig(parsed);
|
||||
expect(missing).toHaveLength(2);
|
||||
expect(missing).toContain("OIDC_CLIENT_SECRET");
|
||||
expect(missing).toContain("OIDC_REDIRECT_URI");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Full configuration scenarios", () => {
|
||||
it("should parse minimal config (auth disabled)", () => {
|
||||
const result = EnvSchema.parse({});
|
||||
expect(result.AUTH_ENABLED).toBe(false);
|
||||
expect(result.OIDC_ENABLED).toBe(false);
|
||||
});
|
||||
it("should parse minimal config (auth disabled)", () => {
|
||||
const result = EnvSchema.parse({});
|
||||
expect(result.AUTH_ENABLED).toBe(false);
|
||||
expect(result.OIDC_ENABLED).toBe(false);
|
||||
});
|
||||
|
||||
it("should parse full production config with auth enabled", () => {
|
||||
const env = {
|
||||
NODE_ENV: "production",
|
||||
PORT: "8080",
|
||||
CORS_ORIGINS: "https://myapp.com",
|
||||
LOG_LEVEL: "warn",
|
||||
AUTH_ENABLED: "true",
|
||||
REGISTRATION_ENABLED: "false",
|
||||
JWT_SECRET: "production-jwt-secret-key-12345",
|
||||
REFRESH_SECRET: "production-refresh-secret-key-12345",
|
||||
COOKIE_SECRET: "production-cookie-secret-key-12345",
|
||||
ACCESS_TOKEN_TTL_MINUTES: "30",
|
||||
REFRESH_TOKEN_TTL_DAYS: "14",
|
||||
};
|
||||
|
||||
const result = EnvSchema.parse(env);
|
||||
|
||||
expect(result.NODE_ENV).toBe("production");
|
||||
expect(result.PORT).toBe(8080);
|
||||
expect(result.CORS_ORIGINS).toBe("https://myapp.com");
|
||||
expect(result.LOG_LEVEL).toBe("warn");
|
||||
expect(result.AUTH_ENABLED).toBe(true);
|
||||
expect(result.REGISTRATION_ENABLED).toBe(false);
|
||||
expect(result.ACCESS_TOKEN_TTL_MINUTES).toBe(30);
|
||||
expect(result.REFRESH_TOKEN_TTL_DAYS).toBe(14);
|
||||
|
||||
// Should pass auth validation
|
||||
const missing = validateAuthSecrets(result);
|
||||
expect(missing).toHaveLength(0);
|
||||
});
|
||||
it("should parse full production config with auth enabled", () => {
|
||||
const env = {
|
||||
NODE_ENV: "production",
|
||||
PORT: "8080",
|
||||
CORS_ORIGINS: "https://myapp.com",
|
||||
LOG_LEVEL: "warn",
|
||||
AUTH_ENABLED: "true",
|
||||
REGISTRATION_ENABLED: "false",
|
||||
JWT_SECRET: "production-jwt-secret-key-12345",
|
||||
REFRESH_SECRET: "production-refresh-secret-key-12345",
|
||||
COOKIE_SECRET: "production-cookie-secret-key-12345",
|
||||
ACCESS_TOKEN_TTL_MINUTES: "30",
|
||||
REFRESH_TOKEN_TTL_DAYS: "14",
|
||||
};
|
||||
|
||||
it("should parse config with OIDC SSO enabled", () => {
|
||||
const env = {
|
||||
AUTH_ENABLED: "true",
|
||||
JWT_SECRET: "production-jwt-secret-key-12345",
|
||||
REFRESH_SECRET: "production-refresh-secret-key-12345",
|
||||
COOKIE_SECRET: "production-cookie-secret-key-12345",
|
||||
OIDC_ENABLED: "true",
|
||||
OIDC_ISSUER_URL: "https://authelia.example.com",
|
||||
OIDC_CLIENT_ID: "medassist",
|
||||
OIDC_CLIENT_SECRET: "super-secret-oidc-secret",
|
||||
OIDC_REDIRECT_URI: "https://medassist.example.com/api/auth/oidc/callback",
|
||||
OIDC_SCOPES: "openid profile email groups",
|
||||
OIDC_USERNAME_CLAIM: "email",
|
||||
OIDC_PROVIDER_NAME: "Authelia",
|
||||
};
|
||||
|
||||
const result = EnvSchema.parse(env);
|
||||
|
||||
expect(result.OIDC_ENABLED).toBe(true);
|
||||
expect(result.OIDC_ISSUER_URL).toBe("https://authelia.example.com");
|
||||
expect(result.OIDC_SCOPES).toBe("openid profile email groups");
|
||||
expect(result.OIDC_USERNAME_CLAIM).toBe("email");
|
||||
expect(result.OIDC_PROVIDER_NAME).toBe("Authelia");
|
||||
|
||||
// Should pass both validations
|
||||
expect(validateAuthSecrets(result)).toHaveLength(0);
|
||||
expect(validateOidcConfig(result)).toHaveLength(0);
|
||||
});
|
||||
const result = EnvSchema.parse(env);
|
||||
|
||||
it("should parse development config", () => {
|
||||
const env = {
|
||||
NODE_ENV: "development",
|
||||
PORT: "3000",
|
||||
LOG_LEVEL: "debug",
|
||||
AUTH_ENABLED: "false",
|
||||
};
|
||||
|
||||
const result = EnvSchema.parse(env);
|
||||
|
||||
expect(result.NODE_ENV).toBe("development");
|
||||
expect(result.LOG_LEVEL).toBe("debug");
|
||||
expect(result.AUTH_ENABLED).toBe(false);
|
||||
});
|
||||
expect(result.NODE_ENV).toBe("production");
|
||||
expect(result.PORT).toBe(8080);
|
||||
expect(result.CORS_ORIGINS).toBe("https://myapp.com");
|
||||
expect(result.LOG_LEVEL).toBe("warn");
|
||||
expect(result.AUTH_ENABLED).toBe(true);
|
||||
expect(result.REGISTRATION_ENABLED).toBe(false);
|
||||
expect(result.ACCESS_TOKEN_TTL_MINUTES).toBe(30);
|
||||
expect(result.REFRESH_TOKEN_TTL_DAYS).toBe(14);
|
||||
|
||||
// Should pass auth validation
|
||||
const missing = validateAuthSecrets(result);
|
||||
expect(missing).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should parse config with OIDC SSO enabled", () => {
|
||||
const env = {
|
||||
AUTH_ENABLED: "true",
|
||||
JWT_SECRET: "production-jwt-secret-key-12345",
|
||||
REFRESH_SECRET: "production-refresh-secret-key-12345",
|
||||
COOKIE_SECRET: "production-cookie-secret-key-12345",
|
||||
OIDC_ENABLED: "true",
|
||||
OIDC_ISSUER_URL: "https://authelia.example.com",
|
||||
OIDC_CLIENT_ID: "medassist",
|
||||
OIDC_CLIENT_SECRET: "super-secret-oidc-secret",
|
||||
OIDC_REDIRECT_URI: "https://medassist.example.com/api/auth/oidc/callback",
|
||||
OIDC_SCOPES: "openid profile email groups",
|
||||
OIDC_USERNAME_CLAIM: "email",
|
||||
OIDC_PROVIDER_NAME: "Authelia",
|
||||
};
|
||||
|
||||
const result = EnvSchema.parse(env);
|
||||
|
||||
expect(result.OIDC_ENABLED).toBe(true);
|
||||
expect(result.OIDC_ISSUER_URL).toBe("https://authelia.example.com");
|
||||
expect(result.OIDC_SCOPES).toBe("openid profile email groups");
|
||||
expect(result.OIDC_USERNAME_CLAIM).toBe("email");
|
||||
expect(result.OIDC_PROVIDER_NAME).toBe("Authelia");
|
||||
|
||||
// Should pass both validations
|
||||
expect(validateAuthSecrets(result)).toHaveLength(0);
|
||||
expect(validateOidcConfig(result)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should parse development config", () => {
|
||||
const env = {
|
||||
NODE_ENV: "development",
|
||||
PORT: "3000",
|
||||
LOG_LEVEL: "debug",
|
||||
AUTH_ENABLED: "false",
|
||||
};
|
||||
|
||||
const result = EnvSchema.parse(env);
|
||||
|
||||
expect(result.NODE_ENV).toBe("development");
|
||||
expect(result.LOG_LEVEL).toBe("debug");
|
||||
expect(result.AUTH_ENABLED).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
+717
-725
File diff suppressed because it is too large
Load Diff
+1010
-838
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+623
-631
File diff suppressed because it is too large
Load Diff
+315
-313
@@ -2,14 +2,14 @@
|
||||
* Tests for /medications/:id/refill and /medications/:id/refills API endpoints.
|
||||
* Tests adding refills to medication stock and retrieving refill history.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildTestApp,
|
||||
closeTestApp,
|
||||
clearTestData,
|
||||
createTestUser,
|
||||
createTestMedication,
|
||||
TestContext,
|
||||
buildTestApp,
|
||||
clearTestData,
|
||||
closeTestApp,
|
||||
createTestMedication,
|
||||
createTestUser,
|
||||
type TestContext,
|
||||
} from "./setup.js";
|
||||
|
||||
// Store userId at module level so routes can access it
|
||||
@@ -20,96 +20,98 @@ let currentUserId = 1;
|
||||
// =============================================================================
|
||||
|
||||
async function registerRefillRoutes(ctx: TestContext) {
|
||||
const { app, client } = ctx;
|
||||
const { app, client } = ctx;
|
||||
|
||||
// POST /medications/:id/refill - Add stock and record history
|
||||
app.post<{ Params: { id: string }; Body: { packsAdded?: number; loosePillsAdded?: number } }>(
|
||||
"/medications/:id/refill",
|
||||
async (request, reply) => {
|
||||
const userId = currentUserId;
|
||||
const medId = parseInt(request.params.id, 10);
|
||||
const { packsAdded = 0, loosePillsAdded = 0 } = request.body || {};
|
||||
// POST /medications/:id/refill - Add stock and record history
|
||||
app.post<{ Params: { id: string }; Body: { packsAdded?: number; loosePillsAdded?: number } }>(
|
||||
"/medications/:id/refill",
|
||||
async (request, reply) => {
|
||||
const userId = currentUserId;
|
||||
const medId = parseInt(request.params.id, 10);
|
||||
const { packsAdded = 0, loosePillsAdded = 0 } = request.body || {};
|
||||
|
||||
// Validate input
|
||||
if (packsAdded < 0 || loosePillsAdded < 0) {
|
||||
return reply.status(400).send({ error: "packsAdded and loosePillsAdded must be non-negative" });
|
||||
}
|
||||
if (packsAdded === 0 && loosePillsAdded === 0) {
|
||||
return reply.status(400).send({ error: "At least one of packsAdded or loosePillsAdded must be greater than 0" });
|
||||
}
|
||||
// Validate input
|
||||
if (packsAdded < 0 || loosePillsAdded < 0) {
|
||||
return reply.status(400).send({ error: "packsAdded and loosePillsAdded must be non-negative" });
|
||||
}
|
||||
if (packsAdded === 0 && loosePillsAdded === 0) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ error: "At least one of packsAdded or loosePillsAdded must be greater than 0" });
|
||||
}
|
||||
|
||||
// Check medication exists and belongs to user
|
||||
const medResult = await client.execute({
|
||||
sql: `SELECT id, pack_count, loose_tablets, blisters_per_pack, pills_per_blister
|
||||
// Check medication exists and belongs to user
|
||||
const medResult = await client.execute({
|
||||
sql: `SELECT id, pack_count, loose_tablets, blisters_per_pack, pills_per_blister
|
||||
FROM medications WHERE id = ? AND user_id = ?`,
|
||||
args: [medId, userId],
|
||||
});
|
||||
args: [medId, userId],
|
||||
});
|
||||
|
||||
if (medResult.rows.length === 0) {
|
||||
return reply.status(404).send({ error: "Medication not found" });
|
||||
}
|
||||
if (medResult.rows.length === 0) {
|
||||
return reply.status(404).send({ error: "Medication not found" });
|
||||
}
|
||||
|
||||
const med = medResult.rows[0];
|
||||
const newPackCount = (med.pack_count as number) + packsAdded;
|
||||
const newLooseTablets = (med.loose_tablets as number) + loosePillsAdded;
|
||||
const pillsPerPack = (med.blisters_per_pack as number) * (med.pills_per_blister as number);
|
||||
const totalPillsAdded = packsAdded * pillsPerPack + loosePillsAdded;
|
||||
const med = medResult.rows[0];
|
||||
const newPackCount = (med.pack_count as number) + packsAdded;
|
||||
const newLooseTablets = (med.loose_tablets as number) + loosePillsAdded;
|
||||
const pillsPerPack = (med.blisters_per_pack as number) * (med.pills_per_blister as number);
|
||||
const totalPillsAdded = packsAdded * pillsPerPack + loosePillsAdded;
|
||||
|
||||
// Update medication stock
|
||||
await client.execute({
|
||||
sql: `UPDATE medications SET pack_count = ?, loose_tablets = ? WHERE id = ?`,
|
||||
args: [newPackCount, newLooseTablets, medId],
|
||||
});
|
||||
// Update medication stock
|
||||
await client.execute({
|
||||
sql: `UPDATE medications SET pack_count = ?, loose_tablets = ? WHERE id = ?`,
|
||||
args: [newPackCount, newLooseTablets, medId],
|
||||
});
|
||||
|
||||
// Record refill history
|
||||
await client.execute({
|
||||
sql: `INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added)
|
||||
// Record refill history
|
||||
await client.execute({
|
||||
sql: `INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
args: [medId, userId, packsAdded, loosePillsAdded],
|
||||
});
|
||||
args: [medId, userId, packsAdded, loosePillsAdded],
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
pillsAdded: totalPillsAdded,
|
||||
newPackCount,
|
||||
newLooseTablets,
|
||||
};
|
||||
}
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
pillsAdded: totalPillsAdded,
|
||||
newPackCount,
|
||||
newLooseTablets,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// GET /medications/:id/refills - Get refill history
|
||||
app.get<{ Params: { id: string } }>("/medications/:id/refills", async (request, reply) => {
|
||||
const userId = currentUserId;
|
||||
const medId = parseInt(request.params.id, 10);
|
||||
// GET /medications/:id/refills - Get refill history
|
||||
app.get<{ Params: { id: string } }>("/medications/:id/refills", async (request, reply) => {
|
||||
const userId = currentUserId;
|
||||
const medId = parseInt(request.params.id, 10);
|
||||
|
||||
// Check medication exists and belongs to user
|
||||
const medResult = await client.execute({
|
||||
sql: `SELECT id FROM medications WHERE id = ? AND user_id = ?`,
|
||||
args: [medId, userId],
|
||||
});
|
||||
// Check medication exists and belongs to user
|
||||
const medResult = await client.execute({
|
||||
sql: `SELECT id FROM medications WHERE id = ? AND user_id = ?`,
|
||||
args: [medId, userId],
|
||||
});
|
||||
|
||||
if (medResult.rows.length === 0) {
|
||||
return reply.status(404).send({ error: "Medication not found" });
|
||||
}
|
||||
if (medResult.rows.length === 0) {
|
||||
return reply.status(404).send({ error: "Medication not found" });
|
||||
}
|
||||
|
||||
// Get refill history, newest first
|
||||
const refillResult = await client.execute({
|
||||
sql: `SELECT id, packs_added, loose_pills_added, refill_date
|
||||
// Get refill history, newest first
|
||||
const refillResult = await client.execute({
|
||||
sql: `SELECT id, packs_added, loose_pills_added, refill_date
|
||||
FROM refill_history
|
||||
WHERE medication_id = ? AND user_id = ?
|
||||
ORDER BY refill_date DESC`,
|
||||
args: [medId, userId],
|
||||
});
|
||||
args: [medId, userId],
|
||||
});
|
||||
|
||||
return {
|
||||
refills: refillResult.rows.map((r) => ({
|
||||
id: r.id,
|
||||
packsAdded: r.packs_added,
|
||||
loosePillsAdded: r.loose_pills_added,
|
||||
refillDate: r.refill_date,
|
||||
})),
|
||||
};
|
||||
});
|
||||
return {
|
||||
refills: refillResult.rows.map((r) => ({
|
||||
id: r.id,
|
||||
packsAdded: r.packs_added,
|
||||
loosePillsAdded: r.loose_pills_added,
|
||||
refillDate: r.refill_date,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -117,278 +119,278 @@ async function registerRefillRoutes(ctx: TestContext) {
|
||||
// =============================================================================
|
||||
|
||||
describe("Refill API", () => {
|
||||
let ctx: TestContext;
|
||||
let userId: number;
|
||||
let medId: number;
|
||||
let ctx: TestContext;
|
||||
let userId: number;
|
||||
let medId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await buildTestApp();
|
||||
await registerRefillRoutes(ctx);
|
||||
await ctx.app.ready();
|
||||
});
|
||||
beforeAll(async () => {
|
||||
ctx = await buildTestApp();
|
||||
await registerRefillRoutes(ctx);
|
||||
await ctx.app.ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closeTestApp(ctx);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await closeTestApp(ctx);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearTestData(ctx.client);
|
||||
// Create test user
|
||||
userId = await createTestUser(ctx.client, { username: "testuser" });
|
||||
// Update the module-level userId so routes use the correct one
|
||||
currentUserId = userId;
|
||||
// Create a test medication with 1 pack (10 blisters × 10 pills = 100 pills/pack)
|
||||
medId = await createTestMedication(ctx.client, {
|
||||
userId,
|
||||
name: "Test Med",
|
||||
packCount: 1,
|
||||
blistersPerPack: 10,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 5,
|
||||
});
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await clearTestData(ctx.client);
|
||||
// Create test user
|
||||
userId = await createTestUser(ctx.client, { username: "testuser" });
|
||||
// Update the module-level userId so routes use the correct one
|
||||
currentUserId = userId;
|
||||
// Create a test medication with 1 pack (10 blisters × 10 pills = 100 pills/pack)
|
||||
medId = await createTestMedication(ctx.client, {
|
||||
userId,
|
||||
name: "Test Med",
|
||||
packCount: 1,
|
||||
blistersPerPack: 10,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 5,
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /medications/:id/refill
|
||||
// ---------------------------------------------------------------------------
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /medications/:id/refill
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("POST /medications/:id/refill", () => {
|
||||
it("should add packs to medication stock", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medId}/refill`,
|
||||
payload: { packsAdded: 2 },
|
||||
});
|
||||
describe("POST /medications/:id/refill", () => {
|
||||
it("should add packs to medication stock", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medId}/refill`,
|
||||
payload: { packsAdded: 2 },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.pillsAdded).toBe(200); // 2 packs × 100 pills
|
||||
expect(data.newPackCount).toBe(3); // 1 + 2
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.pillsAdded).toBe(200); // 2 packs × 100 pills
|
||||
expect(data.newPackCount).toBe(3); // 1 + 2
|
||||
|
||||
// Verify in database
|
||||
const result = await ctx.client.execute({
|
||||
sql: `SELECT pack_count FROM medications WHERE id = ?`,
|
||||
args: [medId],
|
||||
});
|
||||
expect(result.rows[0].pack_count).toBe(3);
|
||||
});
|
||||
// Verify in database
|
||||
const result = await ctx.client.execute({
|
||||
sql: `SELECT pack_count FROM medications WHERE id = ?`,
|
||||
args: [medId],
|
||||
});
|
||||
expect(result.rows[0].pack_count).toBe(3);
|
||||
});
|
||||
|
||||
it("should add loose pills to medication stock", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medId}/refill`,
|
||||
payload: { loosePillsAdded: 15 },
|
||||
});
|
||||
it("should add loose pills to medication stock", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medId}/refill`,
|
||||
payload: { loosePillsAdded: 15 },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.pillsAdded).toBe(15);
|
||||
expect(data.newLooseTablets).toBe(20); // 5 + 15
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.pillsAdded).toBe(15);
|
||||
expect(data.newLooseTablets).toBe(20); // 5 + 15
|
||||
|
||||
// Verify in database
|
||||
const result = await ctx.client.execute({
|
||||
sql: `SELECT loose_tablets FROM medications WHERE id = ?`,
|
||||
args: [medId],
|
||||
});
|
||||
expect(result.rows[0].loose_tablets).toBe(20);
|
||||
});
|
||||
// Verify in database
|
||||
const result = await ctx.client.execute({
|
||||
sql: `SELECT loose_tablets FROM medications WHERE id = ?`,
|
||||
args: [medId],
|
||||
});
|
||||
expect(result.rows[0].loose_tablets).toBe(20);
|
||||
});
|
||||
|
||||
it("should add both packs and loose pills", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medId}/refill`,
|
||||
payload: { packsAdded: 1, loosePillsAdded: 10 },
|
||||
});
|
||||
it("should add both packs and loose pills", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medId}/refill`,
|
||||
payload: { packsAdded: 1, loosePillsAdded: 10 },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.pillsAdded).toBe(110); // 1 pack (100) + 10 loose
|
||||
expect(data.newPackCount).toBe(2);
|
||||
expect(data.newLooseTablets).toBe(15);
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.pillsAdded).toBe(110); // 1 pack (100) + 10 loose
|
||||
expect(data.newPackCount).toBe(2);
|
||||
expect(data.newLooseTablets).toBe(15);
|
||||
});
|
||||
|
||||
it("should record refill in history", async () => {
|
||||
await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medId}/refill`,
|
||||
payload: { packsAdded: 2, loosePillsAdded: 5 },
|
||||
});
|
||||
it("should record refill in history", async () => {
|
||||
await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medId}/refill`,
|
||||
payload: { packsAdded: 2, loosePillsAdded: 5 },
|
||||
});
|
||||
|
||||
// Check history
|
||||
const result = await ctx.client.execute({
|
||||
sql: `SELECT packs_added, loose_pills_added FROM refill_history WHERE medication_id = ?`,
|
||||
args: [medId],
|
||||
});
|
||||
expect(result.rows.length).toBe(1);
|
||||
expect(result.rows[0].packs_added).toBe(2);
|
||||
expect(result.rows[0].loose_pills_added).toBe(5);
|
||||
});
|
||||
// Check history
|
||||
const result = await ctx.client.execute({
|
||||
sql: `SELECT packs_added, loose_pills_added FROM refill_history WHERE medication_id = ?`,
|
||||
args: [medId],
|
||||
});
|
||||
expect(result.rows.length).toBe(1);
|
||||
expect(result.rows[0].packs_added).toBe(2);
|
||||
expect(result.rows[0].loose_pills_added).toBe(5);
|
||||
});
|
||||
|
||||
it("should reject refill with zero amounts", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medId}/refill`,
|
||||
payload: { packsAdded: 0, loosePillsAdded: 0 },
|
||||
});
|
||||
it("should reject refill with zero amounts", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medId}/refill`,
|
||||
payload: { packsAdded: 0, loosePillsAdded: 0 },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error).toContain("At least one");
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error).toContain("At least one");
|
||||
});
|
||||
|
||||
it("should reject refill with negative amounts", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medId}/refill`,
|
||||
payload: { packsAdded: -1 },
|
||||
});
|
||||
it("should reject refill with negative amounts", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medId}/refill`,
|
||||
payload: { packsAdded: -1 },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error).toContain("non-negative");
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error).toContain("non-negative");
|
||||
});
|
||||
|
||||
it("should return 404 for non-existent medication", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/99999/refill`,
|
||||
payload: { packsAdded: 1 },
|
||||
});
|
||||
it("should return 404 for non-existent medication", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/99999/refill`,
|
||||
payload: { packsAdded: 1 },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(response.json().error).toBe("Medication not found");
|
||||
});
|
||||
});
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(response.json().error).toBe("Medication not found");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /medications/:id/refills
|
||||
// ---------------------------------------------------------------------------
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /medications/:id/refills
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("GET /medications/:id/refills", () => {
|
||||
it("should return empty array when no refills", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: `/medications/${medId}/refills`,
|
||||
});
|
||||
describe("GET /medications/:id/refills", () => {
|
||||
it("should return empty array when no refills", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: `/medications/${medId}/refills`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ refills: [] });
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ refills: [] });
|
||||
});
|
||||
|
||||
it("should return refill history newest first", async () => {
|
||||
// Add two refills with different values so we can identify them
|
||||
await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medId}/refill`,
|
||||
payload: { packsAdded: 1, loosePillsAdded: 0 },
|
||||
});
|
||||
it("should return refill history newest first", async () => {
|
||||
// Add two refills with different values so we can identify them
|
||||
await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medId}/refill`,
|
||||
payload: { packsAdded: 1, loosePillsAdded: 0 },
|
||||
});
|
||||
|
||||
// Increase delay to ensure different timestamps (SQLite datetime has second precision)
|
||||
await new Promise((r) => setTimeout(r, 1100));
|
||||
// Increase delay to ensure different timestamps (SQLite datetime has second precision)
|
||||
await new Promise((r) => setTimeout(r, 1100));
|
||||
|
||||
await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medId}/refill`,
|
||||
payload: { packsAdded: 0, loosePillsAdded: 20 },
|
||||
});
|
||||
await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medId}/refill`,
|
||||
payload: { packsAdded: 0, loosePillsAdded: 20 },
|
||||
});
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: `/medications/${medId}/refills`,
|
||||
});
|
||||
const response = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: `/medications/${medId}/refills`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data.refills).toHaveLength(2);
|
||||
|
||||
// Newest first (loose pills - added second)
|
||||
expect(data.refills[0].packsAdded).toBe(0);
|
||||
expect(data.refills[0].loosePillsAdded).toBe(20);
|
||||
|
||||
// Older (packs - added first)
|
||||
expect(data.refills[1].packsAdded).toBe(1);
|
||||
expect(data.refills[1].loosePillsAdded).toBe(0);
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data.refills).toHaveLength(2);
|
||||
|
||||
// Each entry should have an id and refillDate
|
||||
for (const refill of data.refills) {
|
||||
expect(refill.id).toBeTypeOf("number");
|
||||
expect(refill.refillDate).toBeTruthy();
|
||||
}
|
||||
});
|
||||
// Newest first (loose pills - added second)
|
||||
expect(data.refills[0].packsAdded).toBe(0);
|
||||
expect(data.refills[0].loosePillsAdded).toBe(20);
|
||||
|
||||
it("should return 404 for non-existent medication", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: `/medications/99999/refills`,
|
||||
});
|
||||
// Older (packs - added first)
|
||||
expect(data.refills[1].packsAdded).toBe(1);
|
||||
expect(data.refills[1].loosePillsAdded).toBe(0);
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(response.json().error).toBe("Medication not found");
|
||||
});
|
||||
});
|
||||
// Each entry should have an id and refillDate
|
||||
for (const refill of data.refills) {
|
||||
expect(refill.id).toBeTypeOf("number");
|
||||
expect(refill.refillDate).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cascade Delete Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
it("should return 404 for non-existent medication", async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: "GET",
|
||||
url: `/medications/99999/refills`,
|
||||
});
|
||||
|
||||
describe("Cascade Delete", () => {
|
||||
it("should delete refill history when medication is deleted", async () => {
|
||||
// Add a refill
|
||||
await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medId}/refill`,
|
||||
payload: { packsAdded: 1 },
|
||||
});
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(response.json().error).toBe("Medication not found");
|
||||
});
|
||||
});
|
||||
|
||||
// Verify refill exists
|
||||
let result = await ctx.client.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM refill_history WHERE medication_id = ?`,
|
||||
args: [medId],
|
||||
});
|
||||
expect(result.rows[0].count).toBe(1);
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cascade Delete Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Delete medication
|
||||
await ctx.client.execute({
|
||||
sql: `DELETE FROM medications WHERE id = ?`,
|
||||
args: [medId],
|
||||
});
|
||||
describe("Cascade Delete", () => {
|
||||
it("should delete refill history when medication is deleted", async () => {
|
||||
// Add a refill
|
||||
await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medId}/refill`,
|
||||
payload: { packsAdded: 1 },
|
||||
});
|
||||
|
||||
// Verify refill history was cascade deleted
|
||||
result = await ctx.client.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM refill_history WHERE medication_id = ?`,
|
||||
args: [medId],
|
||||
});
|
||||
expect(result.rows[0].count).toBe(0);
|
||||
});
|
||||
// Verify refill exists
|
||||
let result = await ctx.client.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM refill_history WHERE medication_id = ?`,
|
||||
args: [medId],
|
||||
});
|
||||
expect(result.rows[0].count).toBe(1);
|
||||
|
||||
it("should delete refill history when user is deleted", async () => {
|
||||
// Add a refill
|
||||
await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medId}/refill`,
|
||||
payload: { packsAdded: 1 },
|
||||
});
|
||||
// Delete medication
|
||||
await ctx.client.execute({
|
||||
sql: `DELETE FROM medications WHERE id = ?`,
|
||||
args: [medId],
|
||||
});
|
||||
|
||||
// Verify refill exists
|
||||
let result = await ctx.client.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM refill_history WHERE user_id = ?`,
|
||||
args: [userId],
|
||||
});
|
||||
expect(result.rows[0].count).toBe(1);
|
||||
// Verify refill history was cascade deleted
|
||||
result = await ctx.client.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM refill_history WHERE medication_id = ?`,
|
||||
args: [medId],
|
||||
});
|
||||
expect(result.rows[0].count).toBe(0);
|
||||
});
|
||||
|
||||
// Delete user
|
||||
await ctx.client.execute({
|
||||
sql: `DELETE FROM users WHERE id = ?`,
|
||||
args: [userId],
|
||||
});
|
||||
it("should delete refill history when user is deleted", async () => {
|
||||
// Add a refill
|
||||
await ctx.app.inject({
|
||||
method: "POST",
|
||||
url: `/medications/${medId}/refill`,
|
||||
payload: { packsAdded: 1 },
|
||||
});
|
||||
|
||||
// Verify refill history was cascade deleted
|
||||
result = await ctx.client.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM refill_history WHERE user_id = ?`,
|
||||
args: [userId],
|
||||
});
|
||||
expect(result.rows[0].count).toBe(0);
|
||||
});
|
||||
});
|
||||
// Verify refill exists
|
||||
let result = await ctx.client.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM refill_history WHERE user_id = ?`,
|
||||
args: [userId],
|
||||
});
|
||||
expect(result.rows[0].count).toBe(1);
|
||||
|
||||
// Delete user
|
||||
await ctx.client.execute({
|
||||
sql: `DELETE FROM users WHERE id = ?`,
|
||||
args: [userId],
|
||||
});
|
||||
|
||||
// Verify refill history was cascade deleted
|
||||
result = await ctx.client.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM refill_history WHERE user_id = ?`,
|
||||
args: [userId],
|
||||
});
|
||||
expect(result.rows[0].count).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+442
-432
@@ -1,499 +1,509 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import Fastify from "fastify";
|
||||
import { existsSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import cookie from "@fastify/cookie";
|
||||
import cors from "@fastify/cors";
|
||||
import sensible from "@fastify/sensible";
|
||||
import cookie from "@fastify/cookie";
|
||||
import { mkdirSync, rmSync, existsSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
import { tmpdir } from "os";
|
||||
import Fastify from "fastify";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
// Import from utils to avoid index.ts import side effects (server start)
|
||||
import {
|
||||
parseCorsOrigins,
|
||||
buildBaseCookieOptions,
|
||||
buildRefreshCookieOptions,
|
||||
buildAppConfig,
|
||||
ensureImagesDirectory,
|
||||
getJwtConfig,
|
||||
buildAppConfig,
|
||||
buildBaseCookieOptions,
|
||||
buildRefreshCookieOptions,
|
||||
ensureImagesDirectory,
|
||||
getJwtConfig,
|
||||
parseCorsOrigins,
|
||||
} from "../utils/server-config.js";
|
||||
|
||||
describe("Index.ts Utility Functions", () => {
|
||||
describe("parseCorsOrigins", () => {
|
||||
it("should parse comma-separated origins", () => {
|
||||
const origins = parseCorsOrigins("http://localhost:5173,http://localhost:4173");
|
||||
expect(origins).toHaveLength(2);
|
||||
expect(origins[0]).toBe("http://localhost:5173");
|
||||
expect(origins[1]).toBe("http://localhost:4173");
|
||||
});
|
||||
describe("parseCorsOrigins", () => {
|
||||
it("should parse comma-separated origins", () => {
|
||||
const origins = parseCorsOrigins("http://localhost:5173,http://localhost:4173");
|
||||
expect(origins).toHaveLength(2);
|
||||
expect(origins[0]).toBe("http://localhost:5173");
|
||||
expect(origins[1]).toBe("http://localhost:4173");
|
||||
});
|
||||
|
||||
it("should handle single origin", () => {
|
||||
const origins = parseCorsOrigins("https://myapp.example.com");
|
||||
expect(origins).toHaveLength(1);
|
||||
expect(origins[0]).toBe("https://myapp.example.com");
|
||||
});
|
||||
it("should handle single origin", () => {
|
||||
const origins = parseCorsOrigins("https://myapp.example.com");
|
||||
expect(origins).toHaveLength(1);
|
||||
expect(origins[0]).toBe("https://myapp.example.com");
|
||||
});
|
||||
|
||||
it("should filter out empty strings", () => {
|
||||
const origins = parseCorsOrigins("http://localhost:5173,,http://localhost:4173,");
|
||||
expect(origins).toHaveLength(2);
|
||||
});
|
||||
it("should filter out empty strings", () => {
|
||||
const origins = parseCorsOrigins("http://localhost:5173,,http://localhost:4173,");
|
||||
expect(origins).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should trim whitespace", () => {
|
||||
const origins = parseCorsOrigins(" http://localhost:5173 , http://localhost:4173 ");
|
||||
expect(origins).toEqual(["http://localhost:5173", "http://localhost:4173"]);
|
||||
});
|
||||
it("should trim whitespace", () => {
|
||||
const origins = parseCorsOrigins(" http://localhost:5173 , http://localhost:4173 ");
|
||||
expect(origins).toEqual(["http://localhost:5173", "http://localhost:4173"]);
|
||||
});
|
||||
|
||||
it("should return empty array for empty string", () => {
|
||||
const origins = parseCorsOrigins("");
|
||||
expect(origins).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
it("should return empty array for empty string", () => {
|
||||
const origins = parseCorsOrigins("");
|
||||
expect(origins).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildBaseCookieOptions", () => {
|
||||
it("should set secure=true in production", () => {
|
||||
const options = buildBaseCookieOptions(15, true);
|
||||
expect(options.secure).toBe(true);
|
||||
expect(options.httpOnly).toBe(true);
|
||||
expect(options.sameSite).toBe("lax");
|
||||
expect(options.path).toBe("/");
|
||||
});
|
||||
describe("buildBaseCookieOptions", () => {
|
||||
it("should set secure=true in production", () => {
|
||||
const options = buildBaseCookieOptions(15, true);
|
||||
expect(options.secure).toBe(true);
|
||||
expect(options.httpOnly).toBe(true);
|
||||
expect(options.sameSite).toBe("lax");
|
||||
expect(options.path).toBe("/");
|
||||
});
|
||||
|
||||
it("should set secure=false in development", () => {
|
||||
const options = buildBaseCookieOptions(15, false);
|
||||
expect(options.secure).toBe(false);
|
||||
});
|
||||
it("should set secure=false in development", () => {
|
||||
const options = buildBaseCookieOptions(15, false);
|
||||
expect(options.secure).toBe(false);
|
||||
});
|
||||
|
||||
it("should calculate maxAge in seconds from minutes", () => {
|
||||
const options = buildBaseCookieOptions(15, false);
|
||||
expect(options.maxAge).toBe(15 * 60); // 900 seconds
|
||||
});
|
||||
it("should calculate maxAge in seconds from minutes", () => {
|
||||
const options = buildBaseCookieOptions(15, false);
|
||||
expect(options.maxAge).toBe(15 * 60); // 900 seconds
|
||||
});
|
||||
|
||||
it("should handle custom TTL values", () => {
|
||||
const options = buildBaseCookieOptions(30, false);
|
||||
expect(options.maxAge).toBe(30 * 60); // 1800 seconds
|
||||
});
|
||||
});
|
||||
it("should handle custom TTL values", () => {
|
||||
const options = buildBaseCookieOptions(30, false);
|
||||
expect(options.maxAge).toBe(30 * 60); // 1800 seconds
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRefreshCookieOptions", () => {
|
||||
it("should extend base options with longer maxAge", () => {
|
||||
const base = buildBaseCookieOptions(15, false);
|
||||
const refresh = buildRefreshCookieOptions(base, 7);
|
||||
|
||||
expect(refresh.httpOnly).toBe(true);
|
||||
expect(refresh.sameSite).toBe("lax");
|
||||
expect(refresh.maxAge).toBe(7 * 24 * 60 * 60); // 7 days in seconds
|
||||
});
|
||||
describe("buildRefreshCookieOptions", () => {
|
||||
it("should extend base options with longer maxAge", () => {
|
||||
const base = buildBaseCookieOptions(15, false);
|
||||
const refresh = buildRefreshCookieOptions(base, 7);
|
||||
|
||||
it("should calculate 14 days correctly", () => {
|
||||
const base = buildBaseCookieOptions(15, false);
|
||||
const refresh = buildRefreshCookieOptions(base, 14);
|
||||
expect(refresh.maxAge).toBe(14 * 24 * 60 * 60); // 1209600 seconds
|
||||
});
|
||||
expect(refresh.httpOnly).toBe(true);
|
||||
expect(refresh.sameSite).toBe("lax");
|
||||
expect(refresh.maxAge).toBe(7 * 24 * 60 * 60); // 7 days in seconds
|
||||
});
|
||||
|
||||
it("should preserve secure flag from base", () => {
|
||||
const base = buildBaseCookieOptions(15, true);
|
||||
const refresh = buildRefreshCookieOptions(base, 7);
|
||||
expect(refresh.secure).toBe(true);
|
||||
});
|
||||
});
|
||||
it("should calculate 14 days correctly", () => {
|
||||
const base = buildBaseCookieOptions(15, false);
|
||||
const refresh = buildRefreshCookieOptions(base, 14);
|
||||
expect(refresh.maxAge).toBe(14 * 24 * 60 * 60); // 1209600 seconds
|
||||
});
|
||||
|
||||
describe("buildAppConfig", () => {
|
||||
it("should build complete config object", () => {
|
||||
const config = buildAppConfig({
|
||||
jwtSecret: "test-jwt-secret",
|
||||
refreshSecret: "test-refresh-secret",
|
||||
accessTtlMinutes: 15,
|
||||
refreshTtlDays: 7,
|
||||
isProduction: false,
|
||||
});
|
||||
it("should preserve secure flag from base", () => {
|
||||
const base = buildBaseCookieOptions(15, true);
|
||||
const refresh = buildRefreshCookieOptions(base, 7);
|
||||
expect(refresh.secure).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
expect(config.accessSecret).toBe("test-jwt-secret");
|
||||
expect(config.refreshSecret).toBe("test-refresh-secret");
|
||||
expect(config.accessTtl).toBe(15);
|
||||
expect(config.refreshTtl).toBe(7);
|
||||
expect(config.cookieOptions).toBeDefined();
|
||||
expect(config.refreshCookieOptions).toBeDefined();
|
||||
});
|
||||
describe("buildAppConfig", () => {
|
||||
it("should build complete config object", () => {
|
||||
const config = buildAppConfig({
|
||||
jwtSecret: "test-jwt-secret",
|
||||
refreshSecret: "test-refresh-secret",
|
||||
accessTtlMinutes: 15,
|
||||
refreshTtlDays: 7,
|
||||
isProduction: false,
|
||||
});
|
||||
|
||||
it("should use empty strings for missing secrets", () => {
|
||||
const config = buildAppConfig({
|
||||
accessTtlMinutes: 15,
|
||||
refreshTtlDays: 7,
|
||||
isProduction: false,
|
||||
});
|
||||
expect(config.accessSecret).toBe("test-jwt-secret");
|
||||
expect(config.refreshSecret).toBe("test-refresh-secret");
|
||||
expect(config.accessTtl).toBe(15);
|
||||
expect(config.refreshTtl).toBe(7);
|
||||
expect(config.cookieOptions).toBeDefined();
|
||||
expect(config.refreshCookieOptions).toBeDefined();
|
||||
});
|
||||
|
||||
expect(config.accessSecret).toBe("");
|
||||
expect(config.refreshSecret).toBe("");
|
||||
});
|
||||
it("should use empty strings for missing secrets", () => {
|
||||
const config = buildAppConfig({
|
||||
accessTtlMinutes: 15,
|
||||
refreshTtlDays: 7,
|
||||
isProduction: false,
|
||||
});
|
||||
|
||||
it("should set secure cookies in production", () => {
|
||||
const config = buildAppConfig({
|
||||
accessTtlMinutes: 15,
|
||||
refreshTtlDays: 7,
|
||||
isProduction: true,
|
||||
});
|
||||
expect(config.accessSecret).toBe("");
|
||||
expect(config.refreshSecret).toBe("");
|
||||
});
|
||||
|
||||
expect(config.cookieOptions.secure).toBe(true);
|
||||
expect(config.refreshCookieOptions.secure).toBe(true);
|
||||
});
|
||||
});
|
||||
it("should set secure cookies in production", () => {
|
||||
const config = buildAppConfig({
|
||||
accessTtlMinutes: 15,
|
||||
refreshTtlDays: 7,
|
||||
isProduction: true,
|
||||
});
|
||||
|
||||
describe("ensureImagesDirectory", () => {
|
||||
const testDir = resolve(tmpdir(), `test-images-dir-${Date.now()}`);
|
||||
expect(config.cookieOptions.secure).toBe(true);
|
||||
expect(config.refreshCookieOptions.secure).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
if (existsSync(testDir)) {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
}
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
});
|
||||
describe("ensureImagesDirectory", () => {
|
||||
const testDir = resolve(tmpdir(), `test-images-dir-${Date.now()}`);
|
||||
|
||||
it("should create directory if it does not exist", () => {
|
||||
const imagesDir = ensureImagesDirectory(testDir);
|
||||
expect(existsSync(imagesDir)).toBe(true);
|
||||
expect(imagesDir).toContain("data/images");
|
||||
});
|
||||
afterEach(() => {
|
||||
try {
|
||||
if (existsSync(testDir)) {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
}
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
it("should return path if directory already exists", () => {
|
||||
const firstCall = ensureImagesDirectory(testDir);
|
||||
const secondCall = ensureImagesDirectory(testDir);
|
||||
expect(firstCall).toBe(secondCall);
|
||||
});
|
||||
});
|
||||
it("should create directory if it does not exist", () => {
|
||||
const imagesDir = ensureImagesDirectory(testDir);
|
||||
expect(existsSync(imagesDir)).toBe(true);
|
||||
expect(imagesDir).toContain("data/images");
|
||||
});
|
||||
|
||||
describe("getJwtConfig", () => {
|
||||
it("should return real secret when auth enabled with secret", () => {
|
||||
const config = getJwtConfig(true, "my-super-secret");
|
||||
expect(config.secret).toBe("my-super-secret");
|
||||
expect(config.cookie.cookieName).toBe("access_token");
|
||||
expect(config.cookie.signed).toBe(false);
|
||||
});
|
||||
it("should return path if directory already exists", () => {
|
||||
const firstCall = ensureImagesDirectory(testDir);
|
||||
const secondCall = ensureImagesDirectory(testDir);
|
||||
expect(firstCall).toBe(secondCall);
|
||||
});
|
||||
});
|
||||
|
||||
it("should return dummy secret when auth disabled", () => {
|
||||
const config = getJwtConfig(false, undefined);
|
||||
expect(config.secret).toBe("auth-disabled-no-secret-needed");
|
||||
});
|
||||
describe("getJwtConfig", () => {
|
||||
it("should return real secret when auth enabled with secret", () => {
|
||||
const config = getJwtConfig(true, "my-super-secret");
|
||||
expect(config.secret).toBe("my-super-secret");
|
||||
expect(config.cookie.cookieName).toBe("access_token");
|
||||
expect(config.cookie.signed).toBe(false);
|
||||
});
|
||||
|
||||
it("should return dummy secret when auth enabled but no secret", () => {
|
||||
const config = getJwtConfig(true, undefined);
|
||||
expect(config.secret).toBe("auth-disabled-no-secret-needed");
|
||||
});
|
||||
it("should return dummy secret when auth disabled", () => {
|
||||
const config = getJwtConfig(false, undefined);
|
||||
expect(config.secret).toBe("auth-disabled-no-secret-needed");
|
||||
});
|
||||
|
||||
it("should return dummy secret when auth enabled with empty secret", () => {
|
||||
const config = getJwtConfig(true, "");
|
||||
expect(config.secret).toBe("auth-disabled-no-secret-needed");
|
||||
});
|
||||
});
|
||||
it("should return dummy secret when auth enabled but no secret", () => {
|
||||
const config = getJwtConfig(true, undefined);
|
||||
expect(config.secret).toBe("auth-disabled-no-secret-needed");
|
||||
});
|
||||
|
||||
it("should return dummy secret when auth enabled with empty secret", () => {
|
||||
const config = getJwtConfig(true, "");
|
||||
expect(config.secret).toBe("auth-disabled-no-secret-needed");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Test the server bootstrap logic without starting the actual server
|
||||
|
||||
describe("Server Bootstrap", () => {
|
||||
describe("Fastify App Configuration", () => {
|
||||
it("should create a Fastify instance with logger", async () => {
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
level: "silent", // Disable logging for tests
|
||||
},
|
||||
});
|
||||
describe("Fastify App Configuration", () => {
|
||||
it("should create a Fastify instance with logger", async () => {
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
level: "silent", // Disable logging for tests
|
||||
},
|
||||
});
|
||||
|
||||
expect(app).toBeDefined();
|
||||
expect(app.log).toBeDefined();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
expect(app).toBeDefined();
|
||||
expect(app.log).toBeDefined();
|
||||
|
||||
it("should register sensible plugin", async () => {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(sensible);
|
||||
|
||||
// Sensible adds error helpers
|
||||
expect(app.httpErrors).toBeDefined();
|
||||
expect(app.httpErrors.notFound).toBeDefined();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("should register cors plugin with multiple origins", async () => {
|
||||
const origins = ["http://localhost:5173", "http://localhost:4173"];
|
||||
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(cors, { origin: origins, credentials: true });
|
||||
|
||||
// Add a test route
|
||||
app.get("/test", async () => ({ ok: true }));
|
||||
|
||||
await app.ready();
|
||||
|
||||
// Test CORS headers
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/test",
|
||||
headers: {
|
||||
origin: "http://localhost:5173",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.headers["access-control-allow-origin"]).toBe("http://localhost:5173");
|
||||
expect(response.headers["access-control-allow-credentials"]).toBe("true");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
it("should register sensible plugin", async () => {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(sensible);
|
||||
|
||||
it("should register cookie plugin", async () => {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(cookie, { secret: "test-cookie-secret" });
|
||||
|
||||
// Add a test route that sets a cookie
|
||||
app.get("/set-cookie", async (request, reply) => {
|
||||
reply.setCookie("test", "value", { path: "/" });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
await app.ready();
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/set-cookie",
|
||||
});
|
||||
|
||||
expect(response.headers["set-cookie"]).toBeDefined();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
// Sensible adds error helpers
|
||||
expect(app.httpErrors).toBeDefined();
|
||||
expect(app.httpErrors.notFound).toBeDefined();
|
||||
|
||||
describe("Config Decorator", () => {
|
||||
it("should create config with auth settings", async () => {
|
||||
const app = Fastify({ logger: false });
|
||||
|
||||
const accessTtlMinutes = 15;
|
||||
const refreshTtlDays = 7;
|
||||
|
||||
const baseCookieOptions = {
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
secure: false, // test environment
|
||||
path: "/",
|
||||
maxAge: accessTtlMinutes * 60,
|
||||
};
|
||||
|
||||
const refreshCookieOptions = {
|
||||
...baseCookieOptions,
|
||||
maxAge: refreshTtlDays * 24 * 60 * 60,
|
||||
};
|
||||
|
||||
app.decorate("config", {
|
||||
accessSecret: "test-jwt-secret",
|
||||
refreshSecret: "test-refresh-secret",
|
||||
accessTtl: accessTtlMinutes,
|
||||
refreshTtl: refreshTtlDays,
|
||||
cookieOptions: baseCookieOptions,
|
||||
refreshCookieOptions,
|
||||
});
|
||||
|
||||
expect((app as any).config.accessTtl).toBe(15);
|
||||
expect((app as any).config.refreshTtl).toBe(7);
|
||||
expect((app as any).config.cookieOptions.httpOnly).toBe(true);
|
||||
expect((app as any).config.refreshCookieOptions.maxAge).toBe(7 * 24 * 60 * 60);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("should calculate cookie maxAge correctly", () => {
|
||||
const accessTtlMinutes = 30;
|
||||
const refreshTtlDays = 14;
|
||||
|
||||
const accessMaxAge = accessTtlMinutes * 60;
|
||||
const refreshMaxAge = refreshTtlDays * 24 * 60 * 60;
|
||||
|
||||
expect(accessMaxAge).toBe(1800); // 30 minutes in seconds
|
||||
expect(refreshMaxAge).toBe(1209600); // 14 days in seconds
|
||||
});
|
||||
});
|
||||
it("should register cors plugin with multiple origins", async () => {
|
||||
const origins = ["http://localhost:5173", "http://localhost:4173"];
|
||||
|
||||
describe("CORS Origins Parsing", () => {
|
||||
it("should parse comma-separated origins", () => {
|
||||
const originsEnv = "http://localhost:5173,http://localhost:4173";
|
||||
const origins = originsEnv.split(",").map((o) => o.trim()).filter(Boolean);
|
||||
|
||||
expect(origins).toHaveLength(2);
|
||||
expect(origins[0]).toBe("http://localhost:5173");
|
||||
expect(origins[1]).toBe("http://localhost:4173");
|
||||
});
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(cors, { origin: origins, credentials: true });
|
||||
|
||||
it("should handle single origin", () => {
|
||||
const originsEnv = "https://myapp.example.com";
|
||||
const origins = originsEnv.split(",").map((o) => o.trim()).filter(Boolean);
|
||||
|
||||
expect(origins).toHaveLength(1);
|
||||
expect(origins[0]).toBe("https://myapp.example.com");
|
||||
});
|
||||
// Add a test route
|
||||
app.get("/test", async () => ({ ok: true }));
|
||||
|
||||
it("should filter out empty strings", () => {
|
||||
const originsEnv = "http://localhost:5173,,http://localhost:4173,";
|
||||
const origins = originsEnv.split(",").map((o) => o.trim()).filter(Boolean);
|
||||
|
||||
expect(origins).toHaveLength(2);
|
||||
});
|
||||
await app.ready();
|
||||
|
||||
it("should trim whitespace", () => {
|
||||
const originsEnv = " http://localhost:5173 , http://localhost:4173 ";
|
||||
const origins = originsEnv.split(",").map((o) => o.trim()).filter(Boolean);
|
||||
|
||||
expect(origins).toEqual(["http://localhost:5173", "http://localhost:4173"]);
|
||||
});
|
||||
});
|
||||
// Test CORS headers
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/test",
|
||||
headers: {
|
||||
origin: "http://localhost:5173",
|
||||
},
|
||||
});
|
||||
|
||||
describe("Route Registration", () => {
|
||||
it("should register multiple route plugins", async () => {
|
||||
const app = Fastify({ logger: false });
|
||||
|
||||
// Mock route plugins
|
||||
const healthRoutes = async (app: any) => {
|
||||
app.get("/health", async () => ({ status: "ok" }));
|
||||
};
|
||||
|
||||
const authRoutes = async (app: any) => {
|
||||
app.post("/auth/login", async () => ({ token: "mock" }));
|
||||
};
|
||||
|
||||
const medicationRoutes = async (app: any) => {
|
||||
app.get("/medications", async () => []);
|
||||
};
|
||||
|
||||
await app.register(healthRoutes);
|
||||
await app.register(authRoutes);
|
||||
await app.register(medicationRoutes);
|
||||
|
||||
await app.ready();
|
||||
|
||||
// Verify routes are registered
|
||||
const routes = app.printRoutes();
|
||||
expect(routes).toContain("health");
|
||||
expect(routes).toContain("auth/login");
|
||||
expect(routes).toContain("medications");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
expect(response.headers["access-control-allow-origin"]).toBe("http://localhost:5173");
|
||||
expect(response.headers["access-control-allow-credentials"]).toBe("true");
|
||||
|
||||
describe("Server Startup", () => {
|
||||
it("should listen on specified port", async () => {
|
||||
const app = Fastify({ logger: false });
|
||||
|
||||
app.get("/test", async () => ({ ok: true }));
|
||||
|
||||
// Use port 0 to get a random available port
|
||||
const address = await app.listen({ port: 0, host: "127.0.0.1" });
|
||||
|
||||
expect(address).toContain("127.0.0.1");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("should handle listen errors gracefully", async () => {
|
||||
const app = Fastify({ logger: false });
|
||||
|
||||
// Try to listen on an invalid port
|
||||
await expect(
|
||||
app.listen({ port: -1, host: "127.0.0.1" })
|
||||
).rejects.toThrow();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
it("should register cookie plugin", async () => {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(cookie, { secret: "test-cookie-secret" });
|
||||
|
||||
describe("Images Directory", () => {
|
||||
it("should construct images directory path correctly", () => {
|
||||
const resolve = (base: string, ...paths: string[]) => {
|
||||
return [base, ...paths].join("/").replace(/\/+/g, "/");
|
||||
};
|
||||
|
||||
const cwd = "/app";
|
||||
const imagesDir = resolve(cwd, "data/images");
|
||||
|
||||
expect(imagesDir).toBe("/app/data/images");
|
||||
});
|
||||
});
|
||||
// Add a test route that sets a cookie
|
||||
app.get("/set-cookie", async (_request, reply) => {
|
||||
reply.setCookie("test", "value", { path: "/" });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
await app.ready();
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/set-cookie",
|
||||
});
|
||||
|
||||
expect(response.headers["set-cookie"]).toBeDefined();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Config Decorator", () => {
|
||||
it("should create config with auth settings", async () => {
|
||||
const app = Fastify({ logger: false });
|
||||
|
||||
const accessTtlMinutes = 15;
|
||||
const refreshTtlDays = 7;
|
||||
|
||||
const baseCookieOptions = {
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
secure: false, // test environment
|
||||
path: "/",
|
||||
maxAge: accessTtlMinutes * 60,
|
||||
};
|
||||
|
||||
const refreshCookieOptions = {
|
||||
...baseCookieOptions,
|
||||
maxAge: refreshTtlDays * 24 * 60 * 60,
|
||||
};
|
||||
|
||||
app.decorate("config", {
|
||||
accessSecret: "test-jwt-secret",
|
||||
refreshSecret: "test-refresh-secret",
|
||||
accessTtl: accessTtlMinutes,
|
||||
refreshTtl: refreshTtlDays,
|
||||
cookieOptions: baseCookieOptions,
|
||||
refreshCookieOptions,
|
||||
});
|
||||
|
||||
expect((app as any).config.accessTtl).toBe(15);
|
||||
expect((app as any).config.refreshTtl).toBe(7);
|
||||
expect((app as any).config.cookieOptions.httpOnly).toBe(true);
|
||||
expect((app as any).config.refreshCookieOptions.maxAge).toBe(7 * 24 * 60 * 60);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("should calculate cookie maxAge correctly", () => {
|
||||
const accessTtlMinutes = 30;
|
||||
const refreshTtlDays = 14;
|
||||
|
||||
const accessMaxAge = accessTtlMinutes * 60;
|
||||
const refreshMaxAge = refreshTtlDays * 24 * 60 * 60;
|
||||
|
||||
expect(accessMaxAge).toBe(1800); // 30 minutes in seconds
|
||||
expect(refreshMaxAge).toBe(1209600); // 14 days in seconds
|
||||
});
|
||||
});
|
||||
|
||||
describe("CORS Origins Parsing", () => {
|
||||
it("should parse comma-separated origins", () => {
|
||||
const originsEnv = "http://localhost:5173,http://localhost:4173";
|
||||
const origins = originsEnv
|
||||
.split(",")
|
||||
.map((o) => o.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
expect(origins).toHaveLength(2);
|
||||
expect(origins[0]).toBe("http://localhost:5173");
|
||||
expect(origins[1]).toBe("http://localhost:4173");
|
||||
});
|
||||
|
||||
it("should handle single origin", () => {
|
||||
const originsEnv = "https://myapp.example.com";
|
||||
const origins = originsEnv
|
||||
.split(",")
|
||||
.map((o) => o.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
expect(origins).toHaveLength(1);
|
||||
expect(origins[0]).toBe("https://myapp.example.com");
|
||||
});
|
||||
|
||||
it("should filter out empty strings", () => {
|
||||
const originsEnv = "http://localhost:5173,,http://localhost:4173,";
|
||||
const origins = originsEnv
|
||||
.split(",")
|
||||
.map((o) => o.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
expect(origins).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should trim whitespace", () => {
|
||||
const originsEnv = " http://localhost:5173 , http://localhost:4173 ";
|
||||
const origins = originsEnv
|
||||
.split(",")
|
||||
.map((o) => o.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
expect(origins).toEqual(["http://localhost:5173", "http://localhost:4173"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Route Registration", () => {
|
||||
it("should register multiple route plugins", async () => {
|
||||
const app = Fastify({ logger: false });
|
||||
|
||||
// Mock route plugins
|
||||
const healthRoutes = async (app: any) => {
|
||||
app.get("/health", async () => ({ status: "ok" }));
|
||||
};
|
||||
|
||||
const authRoutes = async (app: any) => {
|
||||
app.post("/auth/login", async () => ({ token: "mock" }));
|
||||
};
|
||||
|
||||
const medicationRoutes = async (app: any) => {
|
||||
app.get("/medications", async () => []);
|
||||
};
|
||||
|
||||
await app.register(healthRoutes);
|
||||
await app.register(authRoutes);
|
||||
await app.register(medicationRoutes);
|
||||
|
||||
await app.ready();
|
||||
|
||||
// Verify routes are registered
|
||||
const routes = app.printRoutes();
|
||||
expect(routes).toContain("health");
|
||||
expect(routes).toContain("auth/login");
|
||||
expect(routes).toContain("medications");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Server Startup", () => {
|
||||
it("should listen on specified port", async () => {
|
||||
const app = Fastify({ logger: false });
|
||||
|
||||
app.get("/test", async () => ({ ok: true }));
|
||||
|
||||
// Use port 0 to get a random available port
|
||||
const address = await app.listen({ port: 0, host: "127.0.0.1" });
|
||||
|
||||
expect(address).toContain("127.0.0.1");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("should handle listen errors gracefully", async () => {
|
||||
const app = Fastify({ logger: false });
|
||||
|
||||
// Try to listen on an invalid port
|
||||
await expect(app.listen({ port: -1, host: "127.0.0.1" })).rejects.toThrow();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Images Directory", () => {
|
||||
it("should construct images directory path correctly", () => {
|
||||
const resolve = (base: string, ...paths: string[]) => {
|
||||
return [base, ...paths].join("/").replace(/\/+/g, "/");
|
||||
};
|
||||
|
||||
const cwd = "/app";
|
||||
const imagesDir = resolve(cwd, "data/images");
|
||||
|
||||
expect(imagesDir).toBe("/app/data/images");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cookie Options", () => {
|
||||
describe("Production vs Development", () => {
|
||||
it("should set secure=true in production", () => {
|
||||
const isProduction = true;
|
||||
|
||||
const cookieOptions = {
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
secure: isProduction,
|
||||
path: "/",
|
||||
};
|
||||
|
||||
expect(cookieOptions.secure).toBe(true);
|
||||
});
|
||||
describe("Production vs Development", () => {
|
||||
it("should set secure=true in production", () => {
|
||||
const isProduction = true;
|
||||
|
||||
it("should set secure=false in development", () => {
|
||||
const isProduction = false;
|
||||
|
||||
const cookieOptions = {
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
secure: isProduction,
|
||||
path: "/",
|
||||
};
|
||||
|
||||
expect(cookieOptions.secure).toBe(false);
|
||||
});
|
||||
});
|
||||
const cookieOptions = {
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
secure: isProduction,
|
||||
path: "/",
|
||||
};
|
||||
|
||||
expect(cookieOptions.secure).toBe(true);
|
||||
});
|
||||
|
||||
it("should set secure=false in development", () => {
|
||||
const isProduction = false;
|
||||
|
||||
const cookieOptions = {
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
secure: isProduction,
|
||||
path: "/",
|
||||
};
|
||||
|
||||
expect(cookieOptions.secure).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rate Limiting", () => {
|
||||
it("should configure rate limit settings", () => {
|
||||
const rateLimitConfig = {
|
||||
max: 100,
|
||||
timeWindow: "1 minute",
|
||||
};
|
||||
|
||||
expect(rateLimitConfig.max).toBe(100);
|
||||
expect(rateLimitConfig.timeWindow).toBe("1 minute");
|
||||
});
|
||||
it("should configure rate limit settings", () => {
|
||||
const rateLimitConfig = {
|
||||
max: 300,
|
||||
timeWindow: "1 minute",
|
||||
};
|
||||
|
||||
expect(rateLimitConfig.max).toBe(300);
|
||||
expect(rateLimitConfig.timeWindow).toBe("1 minute");
|
||||
});
|
||||
});
|
||||
|
||||
describe("JWT Configuration", () => {
|
||||
it("should configure JWT with auth enabled", () => {
|
||||
const authEnabled = true;
|
||||
const jwtSecret = "my-super-secret-jwt-key";
|
||||
|
||||
const jwtConfig = {
|
||||
secret: authEnabled && jwtSecret ? jwtSecret : "auth-disabled-no-secret-needed",
|
||||
cookie: { cookieName: "access_token", signed: false },
|
||||
};
|
||||
|
||||
expect(jwtConfig.secret).toBe(jwtSecret);
|
||||
expect(jwtConfig.cookie.cookieName).toBe("access_token");
|
||||
expect(jwtConfig.cookie.signed).toBe(false);
|
||||
});
|
||||
it("should configure JWT with auth enabled", () => {
|
||||
const authEnabled = true;
|
||||
const jwtSecret = "my-super-secret-jwt-key";
|
||||
|
||||
it("should use dummy secret with auth disabled", () => {
|
||||
const authEnabled = false;
|
||||
const jwtSecret = undefined;
|
||||
|
||||
const jwtConfig = {
|
||||
secret: authEnabled && jwtSecret ? jwtSecret : "auth-disabled-no-secret-needed",
|
||||
cookie: { cookieName: "access_token", signed: false },
|
||||
};
|
||||
|
||||
expect(jwtConfig.secret).toBe("auth-disabled-no-secret-needed");
|
||||
});
|
||||
const jwtConfig = {
|
||||
secret: authEnabled && jwtSecret ? jwtSecret : "auth-disabled-no-secret-needed",
|
||||
cookie: { cookieName: "access_token", signed: false },
|
||||
};
|
||||
|
||||
expect(jwtConfig.secret).toBe(jwtSecret);
|
||||
expect(jwtConfig.cookie.cookieName).toBe("access_token");
|
||||
expect(jwtConfig.cookie.signed).toBe(false);
|
||||
});
|
||||
|
||||
it("should use dummy secret with auth disabled", () => {
|
||||
const authEnabled = false;
|
||||
const jwtSecret = undefined;
|
||||
|
||||
const jwtConfig = {
|
||||
secret: authEnabled && jwtSecret ? jwtSecret : "auth-disabled-no-secret-needed",
|
||||
cookie: { cookieName: "access_token", signed: false },
|
||||
};
|
||||
|
||||
expect(jwtConfig.secret).toBe("auth-disabled-no-secret-needed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Multipart Configuration", () => {
|
||||
it("should set file size limit to 10MB", () => {
|
||||
const fileSizeLimit = 10 * 1024 * 1024;
|
||||
|
||||
expect(fileSizeLimit).toBe(10485760);
|
||||
});
|
||||
it("should set file size limit to 10MB", () => {
|
||||
const fileSizeLimit = 10 * 1024 * 1024;
|
||||
|
||||
expect(fileSizeLimit).toBe(10485760);
|
||||
});
|
||||
});
|
||||
|
||||
+557
-538
File diff suppressed because it is too large
Load Diff
+552
-549
File diff suppressed because it is too large
Load Diff
+153
-179
@@ -2,17 +2,17 @@
|
||||
* Test setup and utilities for MedAssist backend API tests.
|
||||
* Uses in-memory SQLite for isolation between test files.
|
||||
*/
|
||||
import Fastify, { FastifyInstance } from "fastify";
|
||||
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import cookie from "@fastify/cookie";
|
||||
import jwt from "@fastify/jwt";
|
||||
import sensible from "@fastify/sensible";
|
||||
import fastifyMultipart from "@fastify/multipart";
|
||||
import { createClient, Client } from "@libsql/client";
|
||||
import sensible from "@fastify/sensible";
|
||||
import { type Client, createClient } from "@libsql/client";
|
||||
import { drizzle } from "drizzle-orm/libsql";
|
||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||
import { beforeAll, afterAll, beforeEach } from "vitest";
|
||||
import { resolve, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
|
||||
// Get migrations folder path
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
@@ -26,9 +26,9 @@ export type TestDb = ReturnType<typeof drizzle>;
|
||||
// Test App Builder
|
||||
// =============================================================================
|
||||
export interface TestContext {
|
||||
app: FastifyInstance;
|
||||
db: TestDb;
|
||||
client: Client;
|
||||
app: FastifyInstance;
|
||||
db: TestDb;
|
||||
client: Client;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,43 +36,43 @@ export interface TestContext {
|
||||
* Each test file gets its own isolated database.
|
||||
*/
|
||||
export async function buildTestApp(): Promise<TestContext> {
|
||||
// Create in-memory SQLite database
|
||||
const client = createClient({ url: ":memory:" });
|
||||
const db = drizzle(client);
|
||||
// Create in-memory SQLite database
|
||||
const client = createClient({ url: ":memory:" });
|
||||
const db = drizzle(client);
|
||||
|
||||
// Run schema creation
|
||||
await runTestMigrations(client);
|
||||
// Run schema creation
|
||||
await runTestMigrations(client);
|
||||
|
||||
// Create Fastify app with minimal plugins
|
||||
const app = Fastify({ logger: false });
|
||||
// Create Fastify app with minimal plugins
|
||||
const app = Fastify({ logger: false });
|
||||
|
||||
await app.register(sensible);
|
||||
await app.register(cookie, { secret: "test-cookie-secret" });
|
||||
await app.register(jwt, {
|
||||
secret: "test-jwt-secret",
|
||||
cookie: { cookieName: "access_token", signed: false },
|
||||
});
|
||||
await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } });
|
||||
await app.register(sensible);
|
||||
await app.register(cookie, { secret: "test-cookie-secret" });
|
||||
await app.register(jwt, {
|
||||
secret: "test-jwt-secret",
|
||||
cookie: { cookieName: "access_token", signed: false },
|
||||
});
|
||||
await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } });
|
||||
|
||||
// Decorate config (matches index.ts structure)
|
||||
app.decorate("config", {
|
||||
accessSecret: "test-jwt-secret",
|
||||
refreshSecret: "test-refresh-secret",
|
||||
accessTtl: 15,
|
||||
refreshTtl: 7,
|
||||
cookieOptions: { httpOnly: true, sameSite: "lax", secure: false, path: "/" },
|
||||
refreshCookieOptions: { httpOnly: true, sameSite: "lax", secure: false, path: "/" },
|
||||
});
|
||||
// Decorate config (matches index.ts structure)
|
||||
app.decorate("config", {
|
||||
accessSecret: "test-jwt-secret",
|
||||
refreshSecret: "test-refresh-secret",
|
||||
accessTtl: 15,
|
||||
refreshTtl: 7,
|
||||
cookieOptions: { httpOnly: true, sameSite: "lax", secure: false, path: "/" },
|
||||
refreshCookieOptions: { httpOnly: true, sameSite: "lax", secure: false, path: "/" },
|
||||
});
|
||||
|
||||
return { app, db, client };
|
||||
return { app, db, client };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create test database schema using drizzle-kit migrations
|
||||
*/
|
||||
async function runTestMigrations(client: Client): Promise<void> {
|
||||
const db = drizzle(client);
|
||||
await migrate(db, { migrationsFolder });
|
||||
const db = drizzle(client);
|
||||
await migrate(db, { migrationsFolder });
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -80,193 +80,167 @@ async function runTestMigrations(client: Client): Promise<void> {
|
||||
// =============================================================================
|
||||
|
||||
export interface CreateUserOptions {
|
||||
username?: string;
|
||||
authProvider?: string;
|
||||
username?: string;
|
||||
authProvider?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test user and return the ID
|
||||
*/
|
||||
export async function createTestUser(
|
||||
client: Client,
|
||||
options: CreateUserOptions = {}
|
||||
): Promise<number> {
|
||||
const { username = `user_${Date.now()}`, authProvider = "local" } = options;
|
||||
export async function createTestUser(client: Client, options: CreateUserOptions = {}): Promise<number> {
|
||||
const { username = `user_${Date.now()}`, authProvider = "local" } = options;
|
||||
|
||||
const result = await client.execute({
|
||||
sql: `INSERT INTO users (username, auth_provider) VALUES (?, ?) RETURNING id`,
|
||||
args: [username, authProvider],
|
||||
});
|
||||
const result = await client.execute({
|
||||
sql: `INSERT INTO users (username, auth_provider) VALUES (?, ?) RETURNING id`,
|
||||
args: [username, authProvider],
|
||||
});
|
||||
|
||||
return result.rows[0].id as number;
|
||||
return result.rows[0].id as number;
|
||||
}
|
||||
|
||||
export interface CreateMedicationOptions {
|
||||
userId: number;
|
||||
name?: string;
|
||||
genericName?: string;
|
||||
takenBy?: string[];
|
||||
packCount?: number;
|
||||
blistersPerPack?: number;
|
||||
pillsPerBlister?: number;
|
||||
looseTablets?: number;
|
||||
pillWeightMg?: number;
|
||||
expiryDate?: string | null;
|
||||
notes?: string | null;
|
||||
intakeRemindersEnabled?: boolean;
|
||||
/** Array of { usage, every, start } for each blister schedule */
|
||||
blisters?: Array<{ usage: number; every: number; start: string }>;
|
||||
userId: number;
|
||||
name?: string;
|
||||
genericName?: string;
|
||||
takenBy?: string[];
|
||||
packCount?: number;
|
||||
blistersPerPack?: number;
|
||||
pillsPerBlister?: number;
|
||||
looseTablets?: number;
|
||||
pillWeightMg?: number;
|
||||
expiryDate?: string | null;
|
||||
notes?: string | null;
|
||||
intakeRemindersEnabled?: boolean;
|
||||
/** Array of { usage, every, start } for each blister schedule */
|
||||
blisters?: Array<{ usage: number; every: number; start: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test medication and return the ID
|
||||
*/
|
||||
export async function createTestMedication(
|
||||
client: Client,
|
||||
options: CreateMedicationOptions
|
||||
): Promise<number> {
|
||||
const {
|
||||
userId,
|
||||
name = "Test Medication",
|
||||
genericName = null,
|
||||
takenBy = [],
|
||||
packCount = 1,
|
||||
blistersPerPack = 1,
|
||||
pillsPerBlister = 10,
|
||||
looseTablets = 0,
|
||||
pillWeightMg = null,
|
||||
expiryDate = null,
|
||||
notes = null,
|
||||
intakeRemindersEnabled = false,
|
||||
blisters = [{ usage: 1, every: 1, start: new Date().toISOString() }],
|
||||
} = options;
|
||||
export async function createTestMedication(client: Client, options: CreateMedicationOptions): Promise<number> {
|
||||
const {
|
||||
userId,
|
||||
name = "Test Medication",
|
||||
genericName = null,
|
||||
takenBy = [],
|
||||
packCount = 1,
|
||||
blistersPerPack = 1,
|
||||
pillsPerBlister = 10,
|
||||
looseTablets = 0,
|
||||
pillWeightMg = null,
|
||||
expiryDate = null,
|
||||
notes = null,
|
||||
intakeRemindersEnabled = false,
|
||||
blisters = [{ usage: 1, every: 1, start: new Date().toISOString() }],
|
||||
} = options;
|
||||
|
||||
// Extract arrays from blisters
|
||||
const usageJson = JSON.stringify(blisters.map((b) => b.usage));
|
||||
const everyJson = JSON.stringify(blisters.map((b) => b.every));
|
||||
const startJson = JSON.stringify(blisters.map((b) => b.start));
|
||||
const takenByJson = JSON.stringify(takenBy);
|
||||
// Extract arrays from blisters
|
||||
const usageJson = JSON.stringify(blisters.map((b) => b.usage));
|
||||
const everyJson = JSON.stringify(blisters.map((b) => b.every));
|
||||
const startJson = JSON.stringify(blisters.map((b) => b.start));
|
||||
const takenByJson = JSON.stringify(takenBy);
|
||||
|
||||
const result = await client.execute({
|
||||
sql: `INSERT INTO medications (
|
||||
const result = await client.execute({
|
||||
sql: `INSERT INTO medications (
|
||||
user_id, name, generic_name, taken_by_json,
|
||||
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
|
||||
pill_weight_mg, usage_json, every_json, start_json, expiry_date, notes, intake_reminders_enabled
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
|
||||
args: [
|
||||
userId,
|
||||
name,
|
||||
genericName,
|
||||
takenByJson,
|
||||
packCount,
|
||||
blistersPerPack,
|
||||
pillsPerBlister,
|
||||
looseTablets,
|
||||
pillWeightMg,
|
||||
usageJson,
|
||||
everyJson,
|
||||
startJson,
|
||||
expiryDate,
|
||||
notes,
|
||||
intakeRemindersEnabled ? 1 : 0,
|
||||
],
|
||||
});
|
||||
args: [
|
||||
userId,
|
||||
name,
|
||||
genericName,
|
||||
takenByJson,
|
||||
packCount,
|
||||
blistersPerPack,
|
||||
pillsPerBlister,
|
||||
looseTablets,
|
||||
pillWeightMg,
|
||||
usageJson,
|
||||
everyJson,
|
||||
startJson,
|
||||
expiryDate,
|
||||
notes,
|
||||
intakeRemindersEnabled ? 1 : 0,
|
||||
],
|
||||
});
|
||||
|
||||
return result.rows[0].id as number;
|
||||
return result.rows[0].id as number;
|
||||
}
|
||||
|
||||
export interface CreateShareTokenOptions {
|
||||
userId: number;
|
||||
takenBy: string;
|
||||
token?: string;
|
||||
scheduleDays?: number;
|
||||
expiresAt?: number | null;
|
||||
userId: number;
|
||||
takenBy: string;
|
||||
token?: string;
|
||||
scheduleDays?: number;
|
||||
expiresAt?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test share token and return the token string
|
||||
*/
|
||||
export async function createTestShareToken(
|
||||
client: Client,
|
||||
options: CreateShareTokenOptions
|
||||
): Promise<string> {
|
||||
const {
|
||||
userId,
|
||||
takenBy,
|
||||
token = `test_token_${Date.now()}`,
|
||||
scheduleDays = 30,
|
||||
expiresAt = null,
|
||||
} = options;
|
||||
export async function createTestShareToken(client: Client, options: CreateShareTokenOptions): Promise<string> {
|
||||
const { userId, takenBy, token = `test_token_${Date.now()}`, scheduleDays = 30, expiresAt = null } = options;
|
||||
|
||||
await client.execute({
|
||||
sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, expires_at)
|
||||
await client.execute({
|
||||
sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
args: [userId, token, takenBy, scheduleDays, expiresAt],
|
||||
});
|
||||
args: [userId, token, takenBy, scheduleDays, expiresAt],
|
||||
});
|
||||
|
||||
return token;
|
||||
return token;
|
||||
}
|
||||
|
||||
export interface CreateDoseTrackingOptions {
|
||||
userId: number;
|
||||
doseId: string;
|
||||
markedBy?: string | null;
|
||||
takenAt?: number;
|
||||
userId: number;
|
||||
doseId: string;
|
||||
markedBy?: string | null;
|
||||
takenAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a dose tracking record
|
||||
*/
|
||||
export async function createTestDoseTracking(
|
||||
client: Client,
|
||||
options: CreateDoseTrackingOptions
|
||||
): Promise<void> {
|
||||
const {
|
||||
userId,
|
||||
doseId,
|
||||
markedBy = null,
|
||||
takenAt = Math.floor(Date.now() / 1000),
|
||||
} = options;
|
||||
export async function createTestDoseTracking(client: Client, options: CreateDoseTrackingOptions): Promise<void> {
|
||||
const { userId, doseId, markedBy = null, takenAt = Math.floor(Date.now() / 1000) } = options;
|
||||
|
||||
await client.execute({
|
||||
sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by, taken_at)
|
||||
await client.execute({
|
||||
sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by, taken_at)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
args: [userId, doseId, markedBy, takenAt],
|
||||
});
|
||||
args: [userId, doseId, markedBy, takenAt],
|
||||
});
|
||||
}
|
||||
|
||||
export interface UpdateUserSettingsOptions {
|
||||
userId: number;
|
||||
stockCalculationMode?: "automatic" | "manual";
|
||||
lowStockDays?: number;
|
||||
userId: number;
|
||||
stockCalculationMode?: "automatic" | "manual";
|
||||
lowStockDays?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update user settings
|
||||
*/
|
||||
export async function setUserSettings(
|
||||
client: Client,
|
||||
options: UpdateUserSettingsOptions
|
||||
): Promise<void> {
|
||||
const { userId, stockCalculationMode = "automatic", lowStockDays = 30 } = options;
|
||||
export async function setUserSettings(client: Client, options: UpdateUserSettingsOptions): Promise<void> {
|
||||
const { userId, stockCalculationMode = "automatic", lowStockDays = 30 } = options;
|
||||
|
||||
// Check if settings exist
|
||||
const existing = await client.execute({
|
||||
sql: `SELECT id FROM user_settings WHERE user_id = ?`,
|
||||
args: [userId],
|
||||
});
|
||||
// Check if settings exist
|
||||
const existing = await client.execute({
|
||||
sql: `SELECT id FROM user_settings WHERE user_id = ?`,
|
||||
args: [userId],
|
||||
});
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
await client.execute({
|
||||
sql: `UPDATE user_settings SET stock_calculation_mode = ?, low_stock_days = ? WHERE user_id = ?`,
|
||||
args: [stockCalculationMode, lowStockDays, userId],
|
||||
});
|
||||
} else {
|
||||
await client.execute({
|
||||
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode, low_stock_days) VALUES (?, ?, ?)`,
|
||||
args: [userId, stockCalculationMode, lowStockDays],
|
||||
});
|
||||
}
|
||||
if (existing.rows.length > 0) {
|
||||
await client.execute({
|
||||
sql: `UPDATE user_settings SET stock_calculation_mode = ?, low_stock_days = ? WHERE user_id = ?`,
|
||||
args: [stockCalculationMode, lowStockDays, userId],
|
||||
});
|
||||
} else {
|
||||
await client.execute({
|
||||
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode, low_stock_days) VALUES (?, ?, ?)`,
|
||||
args: [userId, stockCalculationMode, lowStockDays],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -277,22 +251,22 @@ export async function setUserSettings(
|
||||
* Close test app and database connections
|
||||
*/
|
||||
export async function closeTestApp(ctx: TestContext): Promise<void> {
|
||||
await ctx.app.close();
|
||||
ctx.client.close();
|
||||
await ctx.app.close();
|
||||
ctx.client.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all data from test database (between tests)
|
||||
*/
|
||||
export async function clearTestData(client: Client): Promise<void> {
|
||||
// Order matters due to foreign keys
|
||||
await client.execute("DELETE FROM refill_history");
|
||||
await client.execute("DELETE FROM dose_tracking");
|
||||
await client.execute("DELETE FROM share_tokens");
|
||||
await client.execute("DELETE FROM refresh_tokens");
|
||||
await client.execute("DELETE FROM user_settings");
|
||||
await client.execute("DELETE FROM medications");
|
||||
await client.execute("DELETE FROM users");
|
||||
// Order matters due to foreign keys
|
||||
await client.execute("DELETE FROM refill_history");
|
||||
await client.execute("DELETE FROM dose_tracking");
|
||||
await client.execute("DELETE FROM share_tokens");
|
||||
await client.execute("DELETE FROM refresh_tokens");
|
||||
await client.execute("DELETE FROM user_settings");
|
||||
await client.execute("DELETE FROM medications");
|
||||
await client.execute("DELETE FROM users");
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
+513
-521
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,136 +1,136 @@
|
||||
/**
|
||||
* Tests for translations module
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getTranslations, t, getDateLocale, type Language } from "../i18n/translations.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getDateLocale, getTranslations, type Language, t } from "../i18n/translations.js";
|
||||
|
||||
describe("Translations Module", () => {
|
||||
describe("getTranslations", () => {
|
||||
it("should return English translations for 'en'", () => {
|
||||
const translations = getTranslations("en");
|
||||
expect(translations.stockReminder.title).toContain("MedAssist-ng");
|
||||
expect(translations.common.pills).toBe("pills");
|
||||
});
|
||||
describe("getTranslations", () => {
|
||||
it("should return English translations for 'en'", () => {
|
||||
const translations = getTranslations("en");
|
||||
expect(translations.stockReminder.title).toContain("MedAssist-ng");
|
||||
expect(translations.common.pills).toBe("pills");
|
||||
});
|
||||
|
||||
it("should return German translations for 'de'", () => {
|
||||
const translations = getTranslations("de");
|
||||
expect(translations.stockReminder.title).toContain("MedAssist-ng");
|
||||
expect(translations.common.pills).toBe("Tabletten");
|
||||
});
|
||||
it("should return German translations for 'de'", () => {
|
||||
const translations = getTranslations("de");
|
||||
expect(translations.stockReminder.title).toContain("MedAssist-ng");
|
||||
expect(translations.common.pills).toBe("Tabletten");
|
||||
});
|
||||
|
||||
it("should fallback to English for unknown language", () => {
|
||||
const translations = getTranslations("fr" as Language);
|
||||
expect(translations.common.pills).toBe("pills");
|
||||
});
|
||||
it("should fallback to English for unknown language", () => {
|
||||
const translations = getTranslations("fr" as Language);
|
||||
expect(translations.common.pills).toBe("pills");
|
||||
});
|
||||
|
||||
it("should have all required keys in English", () => {
|
||||
const translations = getTranslations("en");
|
||||
|
||||
// Stock reminder keys
|
||||
expect(translations.stockReminder.subject).toBeDefined();
|
||||
expect(translations.stockReminder.title).toBeDefined();
|
||||
expect(translations.stockReminder.description).toBeDefined();
|
||||
expect(translations.stockReminder.tableHeaders.medication).toBeDefined();
|
||||
|
||||
// Intake reminder keys
|
||||
expect(translations.intakeReminder.subject).toBeDefined();
|
||||
expect(translations.intakeReminder.title).toBeDefined();
|
||||
expect(translations.intakeReminder.pills).toBeDefined();
|
||||
expect(translations.intakeReminder.takenBy).toBeDefined();
|
||||
|
||||
// Push notification keys
|
||||
expect(translations.push.stockTitle).toBeDefined();
|
||||
expect(translations.push.intakeTitle).toBeDefined();
|
||||
expect(translations.push.pillsLeft).toBeDefined();
|
||||
expect(translations.push.emptySection).toBeDefined();
|
||||
expect(translations.push.lowSection).toBeDefined();
|
||||
});
|
||||
it("should have all required keys in English", () => {
|
||||
const translations = getTranslations("en");
|
||||
|
||||
it("should have all required keys in German", () => {
|
||||
const translations = getTranslations("de");
|
||||
|
||||
// Stock reminder keys
|
||||
expect(translations.stockReminder.subject).toBeDefined();
|
||||
expect(translations.stockReminder.title).toBeDefined();
|
||||
expect(translations.stockReminder.description).toBeDefined();
|
||||
expect(translations.stockReminder.tableHeaders.medication).toBe("Medikament");
|
||||
|
||||
// Intake reminder keys
|
||||
expect(translations.intakeReminder.subject).toBeDefined();
|
||||
expect(translations.intakeReminder.pills).toBe("Tabletten");
|
||||
expect(translations.intakeReminder.takenBy).toBe("für {name}");
|
||||
});
|
||||
});
|
||||
// Stock reminder keys
|
||||
expect(translations.stockReminder.subject).toBeDefined();
|
||||
expect(translations.stockReminder.title).toBeDefined();
|
||||
expect(translations.stockReminder.description).toBeDefined();
|
||||
expect(translations.stockReminder.tableHeaders.medication).toBeDefined();
|
||||
|
||||
describe("t (template function)", () => {
|
||||
it("should replace single placeholder", () => {
|
||||
const result = t("Hello {name}!", { name: "World" });
|
||||
expect(result).toBe("Hello World!");
|
||||
});
|
||||
// Intake reminder keys
|
||||
expect(translations.intakeReminder.subject).toBeDefined();
|
||||
expect(translations.intakeReminder.title).toBeDefined();
|
||||
expect(translations.intakeReminder.pills).toBeDefined();
|
||||
expect(translations.intakeReminder.takenBy).toBeDefined();
|
||||
|
||||
it("should replace multiple placeholders", () => {
|
||||
const result = t("{count} {type} running low", { count: 3, type: "medications" });
|
||||
expect(result).toBe("3 medications running low");
|
||||
});
|
||||
// Push notification keys
|
||||
expect(translations.push.stockTitle).toBeDefined();
|
||||
expect(translations.push.intakeTitle).toBeDefined();
|
||||
expect(translations.push.pillsLeft).toBeDefined();
|
||||
expect(translations.push.emptySection).toBeDefined();
|
||||
expect(translations.push.lowSection).toBeDefined();
|
||||
});
|
||||
|
||||
it("should replace same placeholder multiple times", () => {
|
||||
const result = t("{name} and {name} again", { name: "test" });
|
||||
expect(result).toBe("test and test again");
|
||||
});
|
||||
it("should have all required keys in German", () => {
|
||||
const translations = getTranslations("de");
|
||||
|
||||
it("should leave unmatched placeholders", () => {
|
||||
const result = t("Hello {name}!", {});
|
||||
expect(result).toBe("Hello {name}!");
|
||||
});
|
||||
// Stock reminder keys
|
||||
expect(translations.stockReminder.subject).toBeDefined();
|
||||
expect(translations.stockReminder.title).toBeDefined();
|
||||
expect(translations.stockReminder.description).toBeDefined();
|
||||
expect(translations.stockReminder.tableHeaders.medication).toBe("Medikament");
|
||||
|
||||
it("should handle numeric values", () => {
|
||||
const result = t("{count} pills left", { count: 42 });
|
||||
expect(result).toBe("42 pills left");
|
||||
});
|
||||
// Intake reminder keys
|
||||
expect(translations.intakeReminder.subject).toBeDefined();
|
||||
expect(translations.intakeReminder.pills).toBe("Tabletten");
|
||||
expect(translations.intakeReminder.takenBy).toBe("für {name}");
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle empty params object", () => {
|
||||
const result = t("No placeholders here", {});
|
||||
expect(result).toBe("No placeholders here");
|
||||
});
|
||||
describe("t (template function)", () => {
|
||||
it("should replace single placeholder", () => {
|
||||
const result = t("Hello {name}!", { name: "World" });
|
||||
expect(result).toBe("Hello World!");
|
||||
});
|
||||
|
||||
it("should work with real translation strings", () => {
|
||||
const translations = getTranslations("en");
|
||||
|
||||
// Stock reminder subject
|
||||
const subject = t(translations.stockReminder.subject, { count: 3, s: "s" });
|
||||
expect(subject).toBe("MedAssist-ng Auto-Reminder: 3 Medications Running Low");
|
||||
|
||||
// Intake reminder description
|
||||
const description = t(translations.intakeReminder.description, { minutes: 30 });
|
||||
expect(description).toBe("Time to take your medication in 30 minutes:");
|
||||
|
||||
// Push notification
|
||||
const push = t(translations.push.pillsAt, { count: 2, time: "08:00" });
|
||||
expect(push).toBe("2 pills at 08:00");
|
||||
});
|
||||
it("should replace multiple placeholders", () => {
|
||||
const result = t("{count} {type} running low", { count: 3, type: "medications" });
|
||||
expect(result).toBe("3 medications running low");
|
||||
});
|
||||
|
||||
it("should work with German translations", () => {
|
||||
const translations = getTranslations("de");
|
||||
|
||||
const subject = t(translations.stockReminder.subject, { count: 2, e: "e" });
|
||||
expect(subject).toBe("MedAssist-ng Auto-Erinnerung: 2 Medikamente wird knapp");
|
||||
|
||||
const takenBy = t(translations.intakeReminder.takenBy, { name: "Daniel" });
|
||||
expect(takenBy).toBe("für Daniel");
|
||||
});
|
||||
});
|
||||
it("should replace same placeholder multiple times", () => {
|
||||
const result = t("{name} and {name} again", { name: "test" });
|
||||
expect(result).toBe("test and test again");
|
||||
});
|
||||
|
||||
describe("getDateLocale", () => {
|
||||
it("should return 'en-US' for English", () => {
|
||||
expect(getDateLocale("en")).toBe("en-US");
|
||||
});
|
||||
it("should leave unmatched placeholders", () => {
|
||||
const result = t("Hello {name}!", {});
|
||||
expect(result).toBe("Hello {name}!");
|
||||
});
|
||||
|
||||
it("should return 'de-DE' for German", () => {
|
||||
expect(getDateLocale("de")).toBe("de-DE");
|
||||
});
|
||||
it("should handle numeric values", () => {
|
||||
const result = t("{count} pills left", { count: 42 });
|
||||
expect(result).toBe("42 pills left");
|
||||
});
|
||||
|
||||
it("should return 'en-US' for unknown language", () => {
|
||||
expect(getDateLocale("fr" as Language)).toBe("en-US");
|
||||
});
|
||||
});
|
||||
it("should handle empty params object", () => {
|
||||
const result = t("No placeholders here", {});
|
||||
expect(result).toBe("No placeholders here");
|
||||
});
|
||||
|
||||
it("should work with real translation strings", () => {
|
||||
const translations = getTranslations("en");
|
||||
|
||||
// Stock reminder subject
|
||||
const subject = t(translations.stockReminder.subject, { count: 3, s: "s" });
|
||||
expect(subject).toBe("MedAssist-ng Auto-Reminder: 3 Medications Running Low");
|
||||
|
||||
// Intake reminder description
|
||||
const description = t(translations.intakeReminder.description, { minutes: 30 });
|
||||
expect(description).toBe("Time to take your medication in 30 minutes:");
|
||||
|
||||
// Push notification
|
||||
const push = t(translations.push.pillsAt, { count: 2, time: "08:00" });
|
||||
expect(push).toBe("2 pills at 08:00");
|
||||
});
|
||||
|
||||
it("should work with German translations", () => {
|
||||
const translations = getTranslations("de");
|
||||
|
||||
const subject = t(translations.stockReminder.subject, { count: 2, e: "e" });
|
||||
expect(subject).toBe("MedAssist-ng Auto-Erinnerung: 2 Medikamente wird knapp");
|
||||
|
||||
const takenBy = t(translations.intakeReminder.takenBy, { name: "Daniel" });
|
||||
expect(takenBy).toBe("für Daniel");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDateLocale", () => {
|
||||
it("should return 'en-US' for English", () => {
|
||||
expect(getDateLocale("en")).toBe("en-US");
|
||||
});
|
||||
|
||||
it("should return 'de-DE' for German", () => {
|
||||
expect(getDateLocale("de")).toBe("de-DE");
|
||||
});
|
||||
|
||||
it("should return 'en-US' for unknown language", () => {
|
||||
expect(getDateLocale("fr" as Language)).toBe("en-US");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Vendored
+22
-22
@@ -3,32 +3,32 @@ import "@fastify/jwt";
|
||||
|
||||
// User type for authenticated requests
|
||||
export interface AuthUser {
|
||||
id: number;
|
||||
username: string;
|
||||
role: string;
|
||||
id: number;
|
||||
username: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyInstance {
|
||||
config: {
|
||||
accessSecret: string;
|
||||
refreshSecret: string;
|
||||
accessTtl: number;
|
||||
refreshTtl: number;
|
||||
cookieOptions: import("@fastify/cookie").CookieSerializeOptions;
|
||||
refreshCookieOptions: import("@fastify/cookie").CookieSerializeOptions;
|
||||
};
|
||||
}
|
||||
|
||||
interface FastifyRequest {
|
||||
user?: AuthUser | null;
|
||||
}
|
||||
interface FastifyInstance {
|
||||
config: {
|
||||
accessSecret: string;
|
||||
refreshSecret: string;
|
||||
accessTtl: number;
|
||||
refreshTtl: number;
|
||||
cookieOptions: import("@fastify/cookie").CookieSerializeOptions;
|
||||
refreshCookieOptions: import("@fastify/cookie").CookieSerializeOptions;
|
||||
};
|
||||
}
|
||||
|
||||
interface FastifyRequest {
|
||||
user?: AuthUser | null;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "@fastify/jwt" {
|
||||
interface FastifyJWT {
|
||||
// Allow flexible payload for access and refresh tokens
|
||||
payload: Record<string, unknown>;
|
||||
user: Record<string, unknown>;
|
||||
}
|
||||
interface FastifyJWT {
|
||||
// Allow flexible payload for access and refresh tokens
|
||||
payload: Record<string, unknown>;
|
||||
user: Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,114 +5,124 @@
|
||||
|
||||
import { getDateLocale, type Language } from "../i18n/translations.js";
|
||||
|
||||
// Legacy type - individual blister schedule (DEPRECATED: use Intake instead)
|
||||
export type Blister = { usage: number; every: number; start: string };
|
||||
|
||||
// New unified intake type with per-intake takenBy
|
||||
export type Intake = {
|
||||
usage: number;
|
||||
every: number;
|
||||
start: string;
|
||||
takenBy: string | null; // Person taking this specific intake (null = use medication-level takenBy)
|
||||
intakeRemindersEnabled: boolean;
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Timezone utilities
|
||||
// =============================================================================
|
||||
|
||||
/** Get current timezone from TZ env variable or default to UTC */
|
||||
export function getTimezone(): string {
|
||||
return process.env.TZ || "UTC";
|
||||
return process.env.TZ || "UTC";
|
||||
}
|
||||
|
||||
/** Format a date in the configured timezone */
|
||||
export function formatInTimezone(date: Date, tz?: string): string {
|
||||
return date.toLocaleString("de-DE", {
|
||||
timeZone: tz ?? getTimezone(),
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
});
|
||||
return date.toLocaleString("de-DE", {
|
||||
timeZone: tz ?? getTimezone(),
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/** Get current hour in the configured timezone */
|
||||
export function getCurrentHourInTimezone(tz?: string): number {
|
||||
const now = new Date();
|
||||
const timeStr = now.toLocaleString("en-US", {
|
||||
timeZone: tz ?? getTimezone(),
|
||||
hour: "numeric",
|
||||
hour12: false
|
||||
});
|
||||
return parseInt(timeStr, 10);
|
||||
const now = new Date();
|
||||
const timeStr = now.toLocaleString("en-US", {
|
||||
timeZone: tz ?? getTimezone(),
|
||||
hour: "numeric",
|
||||
hour12: false,
|
||||
});
|
||||
return parseInt(timeStr, 10);
|
||||
}
|
||||
|
||||
/** Get today's date string in the configured timezone (YYYY-MM-DD) */
|
||||
export function getTodayInTimezone(tz?: string): string {
|
||||
const now = new Date();
|
||||
const parts = now.toLocaleDateString("en-CA", { timeZone: tz ?? getTimezone() }).split("-");
|
||||
return parts.join("-"); // YYYY-MM-DD format
|
||||
const now = new Date();
|
||||
const parts = now.toLocaleDateString("en-CA", { timeZone: tz ?? getTimezone() }).split("-");
|
||||
return parts.join("-"); // YYYY-MM-DD format
|
||||
}
|
||||
|
||||
/** Calculate the next scheduled time for a given reminder hour */
|
||||
export function getNextScheduledTime(reminderHour: number, tz?: string): Date {
|
||||
const now = new Date();
|
||||
const timezone = tz ?? getTimezone();
|
||||
|
||||
// Get current time components in the target timezone
|
||||
const formatter = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: timezone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false
|
||||
});
|
||||
|
||||
const parts = formatter.formatToParts(now);
|
||||
const getPart = (type: string) => parts.find(p => p.type === type)?.value || "0";
|
||||
|
||||
const currentHour = parseInt(getPart("hour"), 10);
|
||||
const currentMinute = parseInt(getPart("minute"), 10);
|
||||
|
||||
// Calculate if we need tomorrow
|
||||
const needTomorrow = currentHour > reminderHour || (currentHour === reminderHour && currentMinute > 0);
|
||||
|
||||
// Handle month overflow simply by adding a day to now if needed
|
||||
let targetDate: Date;
|
||||
if (needTomorrow) {
|
||||
targetDate = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
||||
} else {
|
||||
targetDate = new Date(now);
|
||||
}
|
||||
|
||||
// Get the target date's date string in the timezone
|
||||
const targetFormatter = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: timezone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit"
|
||||
});
|
||||
const [targetYear, targetMonth, targetDay] = targetFormatter.format(targetDate).split("-").map(Number);
|
||||
|
||||
// Now we need to find the UTC time that corresponds to reminderHour:00 on targetDate in the target timezone
|
||||
// Use a search approach: start with a guess and adjust
|
||||
const guessUtc = new Date(Date.UTC(targetYear, targetMonth - 1, targetDay, reminderHour, 0, 0, 0));
|
||||
|
||||
// Check what hour this UTC time corresponds to in the target timezone
|
||||
const checkFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: timezone,
|
||||
hour: "2-digit",
|
||||
hour12: false
|
||||
});
|
||||
|
||||
// Adjust based on the difference
|
||||
const guessHour = parseInt(checkFormatter.format(guessUtc), 10);
|
||||
const hourDiff = guessHour - reminderHour;
|
||||
|
||||
// Apply correction (if guessHour is higher, we need to subtract time)
|
||||
const correctedUtc = new Date(guessUtc.getTime() - hourDiff * 60 * 60 * 1000);
|
||||
|
||||
return correctedUtc;
|
||||
const now = new Date();
|
||||
const timezone = tz ?? getTimezone();
|
||||
|
||||
// Get current time components in the target timezone
|
||||
const formatter = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: timezone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
const parts = formatter.formatToParts(now);
|
||||
const getPart = (type: string) => parts.find((p) => p.type === type)?.value || "0";
|
||||
|
||||
const currentHour = parseInt(getPart("hour"), 10);
|
||||
const currentMinute = parseInt(getPart("minute"), 10);
|
||||
|
||||
// Calculate if we need tomorrow
|
||||
const needTomorrow = currentHour > reminderHour || (currentHour === reminderHour && currentMinute > 0);
|
||||
|
||||
// Handle month overflow simply by adding a day to now if needed
|
||||
let targetDate: Date;
|
||||
if (needTomorrow) {
|
||||
targetDate = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
||||
} else {
|
||||
targetDate = new Date(now);
|
||||
}
|
||||
|
||||
// Get the target date's date string in the timezone
|
||||
const targetFormatter = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: timezone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
const [targetYear, targetMonth, targetDay] = targetFormatter.format(targetDate).split("-").map(Number);
|
||||
|
||||
// Now we need to find the UTC time that corresponds to reminderHour:00 on targetDate in the target timezone
|
||||
// Use a search approach: start with a guess and adjust
|
||||
const guessUtc = new Date(Date.UTC(targetYear, targetMonth - 1, targetDay, reminderHour, 0, 0, 0));
|
||||
|
||||
// Check what hour this UTC time corresponds to in the target timezone
|
||||
const checkFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: timezone,
|
||||
hour: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
// Adjust based on the difference
|
||||
const guessHour = parseInt(checkFormatter.format(guessUtc), 10);
|
||||
const hourDiff = guessHour - reminderHour;
|
||||
|
||||
// Apply correction (if guessHour is higher, we need to subtract time)
|
||||
const correctedUtc = new Date(guessUtc.getTime() - hourDiff * 60 * 60 * 1000);
|
||||
|
||||
return correctedUtc;
|
||||
}
|
||||
|
||||
/** Calculate milliseconds until next check at the given reminder hour */
|
||||
export function getMsUntilNextCheck(reminderHour: number, tz?: string): number {
|
||||
const next = getNextScheduledTime(reminderHour, tz);
|
||||
return next.getTime() - Date.now();
|
||||
const next = getNextScheduledTime(reminderHour, tz);
|
||||
return next.getTime() - Date.now();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -123,56 +133,131 @@ export function getMsUntilNextCheck(reminderHour: number, tz?: string): number {
|
||||
* Parse an ISO datetime string to local timestamp.
|
||||
* Extracts date/time components directly from the string to avoid
|
||||
* timezone conversion issues with Z suffix.
|
||||
*
|
||||
*
|
||||
* "2026-01-23T20:55:00" → treated as local time 20:55
|
||||
* "2026-01-23T20:55:00.000Z" → also treated as local time 20:55 (Z ignored)
|
||||
*/
|
||||
export function parseLocalDateTime(isoString: string): Date {
|
||||
// Extract components: YYYY-MM-DDTHH:MM:SS (ignore Z and milliseconds)
|
||||
const match = isoString.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):?(\d{2})?/);
|
||||
if (!match) {
|
||||
// Fallback to Date parsing if format doesn't match
|
||||
return new Date(isoString);
|
||||
}
|
||||
|
||||
const [, year, month, day, hour, minute, second] = match;
|
||||
// Create date using local time interpretation (no UTC conversion)
|
||||
return new Date(
|
||||
parseInt(year, 10),
|
||||
parseInt(month, 10) - 1, // Month is 0-indexed
|
||||
parseInt(day, 10),
|
||||
parseInt(hour, 10),
|
||||
parseInt(minute, 10),
|
||||
parseInt(second ?? "0", 10)
|
||||
);
|
||||
// Extract components: YYYY-MM-DDTHH:MM:SS (ignore Z and milliseconds)
|
||||
const match = isoString.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):?(\d{2})?/);
|
||||
if (!match) {
|
||||
// Fallback to Date parsing if format doesn't match
|
||||
return new Date(isoString);
|
||||
}
|
||||
|
||||
const [, year, month, day, hour, minute, second] = match;
|
||||
// Create date using local time interpretation (no UTC conversion)
|
||||
return new Date(
|
||||
parseInt(year, 10),
|
||||
parseInt(month, 10) - 1, // Month is 0-indexed
|
||||
parseInt(day, 10),
|
||||
parseInt(hour, 10),
|
||||
parseInt(minute, 10),
|
||||
parseInt(second ?? "0", 10)
|
||||
);
|
||||
}
|
||||
|
||||
/** Parse blister schedules from JSON columns */
|
||||
/** Parse blister schedules from JSON columns (DEPRECATED: use parseIntakesJson instead) */
|
||||
export function parseBlisters(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] {
|
||||
try {
|
||||
const usage = JSON.parse(row.usageJson) as number[];
|
||||
const every = JSON.parse(row.everyJson) as number[];
|
||||
const start = JSON.parse(row.startJson) as string[];
|
||||
const len = Math.min(usage.length, every.length, start.length);
|
||||
const blisters: Blister[] = [];
|
||||
for (let i = 0; i < len; i++) {
|
||||
blisters.push({ usage: usage[i], every: every[i], start: start[i] });
|
||||
}
|
||||
return blisters;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const usage = JSON.parse(row.usageJson) as number[];
|
||||
const every = JSON.parse(row.everyJson) as number[];
|
||||
const start = JSON.parse(row.startJson) as string[];
|
||||
const len = Math.min(usage.length, every.length, start.length);
|
||||
const blisters: Blister[] = [];
|
||||
for (let i = 0; i < len; i++) {
|
||||
blisters.push({ usage: usage[i], every: every[i], start: start[i] });
|
||||
}
|
||||
return blisters;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse intakes from the new unified intakesJson format.
|
||||
* Falls back to legacy parallel arrays if intakesJson is empty.
|
||||
* @param intakesJson - The new unified JSON string
|
||||
* @param legacyRow - Optional legacy row with usageJson, everyJson, startJson for fallback
|
||||
* @param medicationIntakeRemindersEnabled - Medication-level intakeRemindersEnabled (fallback for legacy)
|
||||
*/
|
||||
export function parseIntakesJson(
|
||||
intakesJson: string | null | undefined,
|
||||
legacyRow?: { usageJson: string; everyJson: string; startJson: string },
|
||||
medicationIntakeRemindersEnabled?: boolean
|
||||
): Intake[] {
|
||||
// Try new format first
|
||||
if (intakesJson) {
|
||||
try {
|
||||
const parsed = JSON.parse(intakesJson);
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
return parsed.map((intake: any) => ({
|
||||
usage: typeof intake.usage === "number" ? intake.usage : 0,
|
||||
every: typeof intake.every === "number" ? intake.every : 1,
|
||||
start: typeof intake.start === "string" ? intake.start : new Date().toISOString(),
|
||||
takenBy: typeof intake.takenBy === "string" && intake.takenBy.trim() ? intake.takenBy.trim() : null,
|
||||
intakeRemindersEnabled:
|
||||
typeof intake.intakeRemindersEnabled === "boolean" ? intake.intakeRemindersEnabled : false,
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
// Fall through to legacy parsing
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to legacy parallel arrays
|
||||
if (legacyRow) {
|
||||
const blisters = parseBlisters(legacyRow);
|
||||
return blisters.map((b) => ({
|
||||
usage: b.usage,
|
||||
every: b.every,
|
||||
start: b.start,
|
||||
takenBy: null, // Legacy format has no per-intake takenBy
|
||||
intakeRemindersEnabled: medicationIntakeRemindersEnabled ?? false,
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert intakes to legacy blister format (for backward compatibility)
|
||||
*/
|
||||
export function intakesToBlisters(intakes: Intake[]): Blister[] {
|
||||
return intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start }));
|
||||
}
|
||||
|
||||
/** Parse takenByJson to array of strings */
|
||||
export function parseTakenByJson(takenByJson: string | null | undefined): string[] {
|
||||
if (!takenByJson) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(takenByJson);
|
||||
return Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string" && s.trim()) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
if (!takenByJson) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(takenByJson);
|
||||
return Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string" && s.trim()) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all unique takenBy values from both medication-level and intake-level.
|
||||
* Used for filtering and sharing functionality.
|
||||
*/
|
||||
export function getAllTakenByForMedication(medicationTakenBy: string[], intakes: Intake[]): string[] {
|
||||
const allPeople = new Set<string>(medicationTakenBy);
|
||||
for (const intake of intakes) {
|
||||
if (intake.takenBy) {
|
||||
allPeople.add(intake.takenBy);
|
||||
}
|
||||
}
|
||||
return Array.from(allPeople);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a person takes this medication (either via medication-level or intake-level takenBy).
|
||||
*/
|
||||
export function personTakesMedication(person: string, medicationTakenBy: string[], intakes: Intake[]): boolean {
|
||||
if (medicationTakenBy.includes(person)) return true;
|
||||
return intakes.some((intake) => intake.takenBy === person);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -181,26 +266,26 @@ export function parseTakenByJson(takenByJson: string | null | undefined): string
|
||||
|
||||
/** Calculate daily usage from blisters */
|
||||
export function calculateDailyUsage(blisters: Blister[]): number {
|
||||
return blisters.reduce((sum, s) => sum + s.usage / s.every, 0);
|
||||
return blisters.reduce((sum, s) => sum + s.usage / s.every, 0);
|
||||
}
|
||||
|
||||
/** Calculate depletion information for a medication */
|
||||
export function calculateDepletionInfo(
|
||||
med: { count: number; blisters: Blister[] },
|
||||
language: Language
|
||||
med: { count: number; blisters: Blister[] },
|
||||
language: Language
|
||||
): { daysLeft: number | null; depletionDate: string | null } {
|
||||
const dailyUsage = calculateDailyUsage(med.blisters);
|
||||
if (dailyUsage <= 0) return { daysLeft: null, depletionDate: null };
|
||||
|
||||
const daysLeft = Math.floor(med.count / dailyUsage);
|
||||
const depletionMs = Date.now() + daysLeft * 86_400_000;
|
||||
const depletionDate = new Date(depletionMs).toLocaleDateString(getDateLocale(language), {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
});
|
||||
|
||||
return { daysLeft, depletionDate };
|
||||
const dailyUsage = calculateDailyUsage(med.blisters);
|
||||
if (dailyUsage <= 0) return { daysLeft: null, depletionDate: null };
|
||||
|
||||
const daysLeft = Math.floor(med.count / dailyUsage);
|
||||
const depletionMs = Date.now() + daysLeft * 86_400_000;
|
||||
const depletionDate = new Date(depletionMs).toLocaleDateString(getDateLocale(language), {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
});
|
||||
|
||||
return { daysLeft, depletionDate };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -208,152 +293,175 @@ export function calculateDepletionInfo(
|
||||
// =============================================================================
|
||||
|
||||
export type UpcomingIntake = {
|
||||
medName: string;
|
||||
usage: number;
|
||||
intakeTime: Date;
|
||||
intakeTimeStr: string;
|
||||
takenBy: string[];
|
||||
pillWeightMg: number | null;
|
||||
medName: string;
|
||||
medicationId?: number;
|
||||
blisterIndex?: number;
|
||||
usage: number;
|
||||
intakeTime: Date;
|
||||
intakeTimeStr: string;
|
||||
takenBy: string | null; // Single person for this intake (null = no specific person)
|
||||
pillWeightMg: number | null;
|
||||
doseUnit?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
/**
|
||||
* Get all intakes for today (past and future) - used for repeat reminders.
|
||||
* Returns all intakes scheduled for today in user's timezone.
|
||||
* Now uses per-intake takenBy instead of medication-level.
|
||||
*/
|
||||
export function getTodaysIntakes(
|
||||
medName: string,
|
||||
blisters: Blister[],
|
||||
takenBy: string[],
|
||||
pillWeightMg: number | null,
|
||||
locale: string,
|
||||
tz?: string
|
||||
medName: string,
|
||||
intakes: Intake[],
|
||||
medicationTakenBy: string[], // Medication-level takenBy as fallback
|
||||
pillWeightMg: number | null,
|
||||
locale: string,
|
||||
tz?: string,
|
||||
medicationId?: number,
|
||||
doseUnit?: string
|
||||
): UpcomingIntake[] {
|
||||
const timezone = tz ?? getTimezone();
|
||||
const now = new Date();
|
||||
|
||||
// Get start and end of today in user's timezone
|
||||
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: timezone }));
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
|
||||
const todayEnd = new Date(now.toLocaleString("en-US", { timeZone: timezone }));
|
||||
todayEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
const intakes: UpcomingIntake[] = [];
|
||||
|
||||
for (const blister of blisters) {
|
||||
const startTime = parseLocalDateTime(blister.start).getTime();
|
||||
const intervalMs = blister.every * 24 * 60 * 60 * 1000;
|
||||
|
||||
if (intervalMs <= 0) continue;
|
||||
|
||||
// Find all occurrences that fall within today
|
||||
let currentTime = startTime;
|
||||
|
||||
// If start is in the past, calculate the first occurrence on or after todayStart
|
||||
if (currentTime < todayStart.getTime()) {
|
||||
const elapsed = todayStart.getTime() - startTime;
|
||||
const intervals = Math.floor(elapsed / intervalMs);
|
||||
currentTime = startTime + intervals * intervalMs;
|
||||
}
|
||||
|
||||
// Collect all intakes for today
|
||||
while (currentTime <= todayEnd.getTime()) {
|
||||
if (currentTime >= todayStart.getTime()) {
|
||||
const intakeDate = new Date(currentTime);
|
||||
intakes.push({
|
||||
medName,
|
||||
usage: blister.usage,
|
||||
intakeTime: intakeDate,
|
||||
intakeTimeStr: intakeDate.toLocaleTimeString(locale, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: timezone
|
||||
}),
|
||||
takenBy,
|
||||
pillWeightMg,
|
||||
});
|
||||
}
|
||||
currentTime += intervalMs;
|
||||
}
|
||||
}
|
||||
|
||||
return intakes;
|
||||
const timezone = tz ?? getTimezone();
|
||||
const now = new Date();
|
||||
|
||||
// Get start and end of today in user's timezone
|
||||
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: timezone }));
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
|
||||
const todayEnd = new Date(now.toLocaleString("en-US", { timeZone: timezone }));
|
||||
todayEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
const result: UpcomingIntake[] = [];
|
||||
|
||||
for (let blisterIdx = 0; blisterIdx < intakes.length; blisterIdx++) {
|
||||
const intake = intakes[blisterIdx];
|
||||
const startTime = parseLocalDateTime(intake.start).getTime();
|
||||
const intervalMs = intake.every * 24 * 60 * 60 * 1000;
|
||||
|
||||
if (intervalMs <= 0) continue;
|
||||
|
||||
// Determine takenBy for this intake
|
||||
// If intake has its own takenBy, use it; otherwise null (no specific person)
|
||||
const effectiveTakenBy = intake.takenBy || null;
|
||||
|
||||
// Find all occurrences that fall within today
|
||||
let currentTime = startTime;
|
||||
|
||||
// If start is in the past, calculate the first occurrence on or after todayStart
|
||||
if (currentTime < todayStart.getTime()) {
|
||||
const elapsed = todayStart.getTime() - startTime;
|
||||
const intervals = Math.floor(elapsed / intervalMs);
|
||||
currentTime = startTime + intervals * intervalMs;
|
||||
}
|
||||
|
||||
// Collect all intakes for today
|
||||
while (currentTime <= todayEnd.getTime()) {
|
||||
if (currentTime >= todayStart.getTime()) {
|
||||
const intakeDate = new Date(currentTime);
|
||||
result.push({
|
||||
medName,
|
||||
medicationId,
|
||||
blisterIndex: blisterIdx,
|
||||
usage: intake.usage,
|
||||
intakeTime: intakeDate,
|
||||
intakeTimeStr: intakeDate.toLocaleTimeString(locale, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: timezone,
|
||||
}),
|
||||
takenBy: effectiveTakenBy,
|
||||
pillWeightMg,
|
||||
doseUnit,
|
||||
});
|
||||
}
|
||||
currentTime += intervalMs;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Get upcoming intakes that fall within the reminder window.
|
||||
* Returns intakes that should be notified about right now.
|
||||
* Now uses per-intake takenBy instead of medication-level.
|
||||
*/
|
||||
export function getUpcomingIntakes(
|
||||
medName: string,
|
||||
blisters: Blister[],
|
||||
minutesBefore: number,
|
||||
takenBy: string[],
|
||||
pillWeightMg: number | null,
|
||||
locale: string,
|
||||
tz?: string,
|
||||
nowOverride?: number
|
||||
medName: string,
|
||||
intakes: Intake[],
|
||||
minutesBefore: number,
|
||||
medicationTakenBy: string[], // Medication-level takenBy as fallback
|
||||
pillWeightMg: number | null,
|
||||
locale: string,
|
||||
tz?: string,
|
||||
nowOverride?: number,
|
||||
medicationId?: number,
|
||||
doseUnit?: string
|
||||
): UpcomingIntake[] {
|
||||
const now = nowOverride ?? Date.now();
|
||||
const timezone = tz ?? getTimezone();
|
||||
|
||||
// Window to detect if "now" is the right time to send reminder
|
||||
// We check if the notify time (intake - minutesBefore) falls within current minute ±1
|
||||
const windowStart = now - 2 * 60 * 1000; // 2 minutes ago (catch slightly late checks)
|
||||
const windowEnd = now + 1 * 60 * 1000; // 1 minute from now
|
||||
|
||||
const upcoming: UpcomingIntake[] = [];
|
||||
|
||||
for (const blister of blisters) {
|
||||
const startTime = parseLocalDateTime(blister.start).getTime();
|
||||
const intervalMs = blister.every * 24 * 60 * 60 * 1000;
|
||||
|
||||
if (intervalMs <= 0) continue;
|
||||
|
||||
// Find the next scheduled intake time (could be today or in the future)
|
||||
let nextTime = startTime;
|
||||
|
||||
// If start is in the past, calculate occurrences
|
||||
if (nextTime < now) {
|
||||
const elapsed = now - startTime;
|
||||
const intervals = Math.floor(elapsed / intervalMs);
|
||||
|
||||
// Check the current occurrence (today's scheduled time, even if past)
|
||||
const currentOccurrence = startTime + intervals * intervalMs;
|
||||
// And the next occurrence
|
||||
const nextOccurrence = startTime + (intervals + 1) * intervalMs;
|
||||
|
||||
// If today's occurrence is within the reminder window, use it
|
||||
// (intake hasn't happened yet, we should remind)
|
||||
const currentNotifyTime = currentOccurrence - minutesBefore * 60 * 1000;
|
||||
if (currentNotifyTime >= windowStart && currentOccurrence > now) {
|
||||
nextTime = currentOccurrence;
|
||||
} else {
|
||||
nextTime = nextOccurrence;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate when we should notify for this intake
|
||||
const notifyTime = nextTime - minutesBefore * 60 * 1000;
|
||||
|
||||
if (notifyTime >= windowStart && notifyTime <= windowEnd) {
|
||||
const intakeDate = new Date(nextTime);
|
||||
upcoming.push({
|
||||
medName,
|
||||
usage: blister.usage,
|
||||
intakeTime: intakeDate,
|
||||
intakeTimeStr: intakeDate.toLocaleTimeString(locale, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: timezone
|
||||
}),
|
||||
takenBy,
|
||||
pillWeightMg,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return upcoming;
|
||||
const now = nowOverride ?? Date.now();
|
||||
const timezone = tz ?? getTimezone();
|
||||
|
||||
// Get the current minute (truncated to minute boundary for precise matching)
|
||||
const currentMinuteStart = Math.floor(now / 60000) * 60000;
|
||||
const currentMinuteEnd = currentMinuteStart + 60000;
|
||||
|
||||
const upcoming: UpcomingIntake[] = [];
|
||||
|
||||
for (let blisterIdx = 0; blisterIdx < intakes.length; blisterIdx++) {
|
||||
const intake = intakes[blisterIdx];
|
||||
const startTime = parseLocalDateTime(intake.start).getTime();
|
||||
const intervalMs = intake.every * 24 * 60 * 60 * 1000;
|
||||
|
||||
if (intervalMs <= 0) continue;
|
||||
|
||||
// Determine takenBy for this intake
|
||||
const effectiveTakenBy = intake.takenBy || null;
|
||||
|
||||
// Find the next scheduled intake time (could be today or in the future)
|
||||
let nextTime = startTime;
|
||||
|
||||
// If start is in the past, calculate occurrences
|
||||
if (nextTime < now) {
|
||||
const elapsed = now - startTime;
|
||||
const intervals = Math.floor(elapsed / intervalMs);
|
||||
|
||||
// Check the current occurrence (today's scheduled time, even if past)
|
||||
const currentOccurrence = startTime + intervals * intervalMs;
|
||||
// And the next occurrence
|
||||
const nextOccurrence = startTime + (intervals + 1) * intervalMs;
|
||||
|
||||
// If today's occurrence notification time falls in current minute and intake hasn't happened
|
||||
const currentNotifyTime = currentOccurrence - minutesBefore * 60 * 1000;
|
||||
if (currentNotifyTime >= currentMinuteStart && currentOccurrence > now) {
|
||||
nextTime = currentOccurrence;
|
||||
} else {
|
||||
nextTime = nextOccurrence;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate when we should notify for this intake
|
||||
const notifyTime = nextTime - minutesBefore * 60 * 1000;
|
||||
|
||||
// Check if notifyTime falls within the current minute (precise matching)
|
||||
if (notifyTime >= currentMinuteStart && notifyTime < currentMinuteEnd) {
|
||||
const intakeDate = new Date(nextTime);
|
||||
upcoming.push({
|
||||
medName,
|
||||
medicationId,
|
||||
blisterIndex: blisterIdx,
|
||||
usage: intake.usage,
|
||||
intakeTime: intakeDate,
|
||||
intakeTimeStr: intakeDate.toLocaleTimeString(locale, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: timezone,
|
||||
}),
|
||||
takenBy: effectiveTakenBy,
|
||||
pillWeightMg,
|
||||
doseUnit,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return upcoming;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -361,102 +469,106 @@ export function getUpcomingIntakes(
|
||||
// =============================================================================
|
||||
|
||||
export type ReminderState = {
|
||||
lastAutoEmailSent: string | null;
|
||||
lastAutoEmailDate: string | null;
|
||||
notifiedMedications: string[];
|
||||
nextScheduledCheck: string | null;
|
||||
lastNotificationType: "stock" | "intake" | null;
|
||||
lastNotificationChannel: "email" | "push" | "both" | null;
|
||||
lastAutoEmailSent: string | null;
|
||||
lastAutoEmailDate: string | null;
|
||||
notifiedMedications: string[];
|
||||
nextScheduledCheck: string | null;
|
||||
lastNotificationType: "stock" | "intake" | null;
|
||||
lastNotificationChannel: "email" | "push" | "both" | null;
|
||||
};
|
||||
|
||||
export type IntakeReminderEntry = {
|
||||
firstSentAt: number; // Timestamp when first reminder was sent
|
||||
lastSentAt: number; // Timestamp when last reminder was sent
|
||||
sendCount: number; // How many times reminder was sent
|
||||
firstSentAt: number; // Timestamp when first reminder was sent
|
||||
lastSentAt: number; // Timestamp when last reminder was sent
|
||||
sendCount: number; // How many times NAGGING reminder was sent (not counting advance)
|
||||
advanceSent?: boolean; // Whether the advance reminder (15 min before) was sent
|
||||
};
|
||||
|
||||
export type IntakeReminderState = {
|
||||
reminders: Record<string, IntakeReminderEntry>; // key -> entry
|
||||
reminders: Record<string, IntakeReminderEntry>; // key -> entry
|
||||
};
|
||||
|
||||
/** Create default reminder state */
|
||||
export function createDefaultReminderState(): ReminderState {
|
||||
return {
|
||||
lastAutoEmailSent: null,
|
||||
lastAutoEmailDate: null,
|
||||
notifiedMedications: [],
|
||||
nextScheduledCheck: null,
|
||||
lastNotificationType: null,
|
||||
lastNotificationChannel: null,
|
||||
};
|
||||
return {
|
||||
lastAutoEmailSent: null,
|
||||
lastAutoEmailDate: null,
|
||||
notifiedMedications: [],
|
||||
nextScheduledCheck: null,
|
||||
lastNotificationType: null,
|
||||
lastNotificationChannel: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Create default intake reminder state */
|
||||
export function createDefaultIntakeReminderState(): IntakeReminderState {
|
||||
return { reminders: {} };
|
||||
return { reminders: {} };
|
||||
}
|
||||
|
||||
/** Parse reminder state from JSON string */
|
||||
export function parseReminderState(json: string): ReminderState {
|
||||
try {
|
||||
const saved = JSON.parse(json);
|
||||
return {
|
||||
lastAutoEmailSent: saved.lastAutoEmailSent ?? null,
|
||||
lastAutoEmailDate: saved.lastAutoEmailDate ?? null,
|
||||
notifiedMedications: saved.notifiedMedications ?? [],
|
||||
nextScheduledCheck: saved.nextScheduledCheck ?? null,
|
||||
lastNotificationType: saved.lastNotificationType ?? null,
|
||||
lastNotificationChannel: saved.lastNotificationChannel ?? null,
|
||||
};
|
||||
} catch {
|
||||
return createDefaultReminderState();
|
||||
}
|
||||
try {
|
||||
const saved = JSON.parse(json);
|
||||
return {
|
||||
lastAutoEmailSent: saved.lastAutoEmailSent ?? null,
|
||||
lastAutoEmailDate: saved.lastAutoEmailDate ?? null,
|
||||
notifiedMedications: saved.notifiedMedications ?? [],
|
||||
nextScheduledCheck: saved.nextScheduledCheck ?? null,
|
||||
lastNotificationType: saved.lastNotificationType ?? null,
|
||||
lastNotificationChannel: saved.lastNotificationChannel ?? null,
|
||||
};
|
||||
} catch {
|
||||
return createDefaultReminderState();
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse intake reminder state from JSON string (backward compatible) */
|
||||
export function parseIntakeReminderState(json: string): IntakeReminderState {
|
||||
try {
|
||||
const saved = JSON.parse(json);
|
||||
|
||||
// Backward compatibility: convert old array format to new map format
|
||||
if (Array.isArray(saved.sentReminders)) {
|
||||
const reminders: Record<string, IntakeReminderEntry> = {};
|
||||
const now = Date.now();
|
||||
for (const key of saved.sentReminders) {
|
||||
reminders[key] = {
|
||||
firstSentAt: now,
|
||||
lastSentAt: now,
|
||||
sendCount: 1,
|
||||
};
|
||||
}
|
||||
return { reminders };
|
||||
}
|
||||
|
||||
// New format
|
||||
return {
|
||||
reminders: saved.reminders ?? {},
|
||||
};
|
||||
} catch {
|
||||
return createDefaultIntakeReminderState();
|
||||
}
|
||||
try {
|
||||
const saved = JSON.parse(json);
|
||||
|
||||
// Backward compatibility: convert old array format to new map format
|
||||
if (Array.isArray(saved.sentReminders)) {
|
||||
const reminders: Record<string, IntakeReminderEntry> = {};
|
||||
const now = Date.now();
|
||||
for (const key of saved.sentReminders) {
|
||||
reminders[key] = {
|
||||
firstSentAt: now,
|
||||
lastSentAt: now,
|
||||
sendCount: 1,
|
||||
};
|
||||
}
|
||||
return { reminders };
|
||||
}
|
||||
|
||||
// New format
|
||||
return {
|
||||
reminders: saved.reminders ?? {},
|
||||
};
|
||||
} catch {
|
||||
return createDefaultIntakeReminderState();
|
||||
}
|
||||
}
|
||||
|
||||
/** Clean up old intake reminder entries (older than given milliseconds) */
|
||||
/** Clean up old intake reminder entries (using timezone-aware day check) */
|
||||
export function cleanOldIntakeReminders(reminders: Record<string, IntakeReminderEntry>, tz: string): Record<string, IntakeReminderEntry> {
|
||||
// Get start of today in user's timezone
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
const todayStartMs = todayStart.getTime();
|
||||
|
||||
// Keep only reminders from today onwards (based on dose timestamp in key)
|
||||
const cleaned: Record<string, IntakeReminderEntry> = {};
|
||||
for (const [key, entry] of Object.entries(reminders)) {
|
||||
const timestamp = parseInt(key.split(":").pop() || "0", 10);
|
||||
if (timestamp >= todayStartMs) {
|
||||
cleaned[key] = entry;
|
||||
}
|
||||
}
|
||||
return cleaned;
|
||||
export function cleanOldIntakeReminders(
|
||||
reminders: Record<string, IntakeReminderEntry>,
|
||||
tz: string
|
||||
): Record<string, IntakeReminderEntry> {
|
||||
// Get start of today in user's timezone
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
const todayStartMs = todayStart.getTime();
|
||||
|
||||
// Keep only reminders from today onwards (based on dose timestamp in key)
|
||||
const cleaned: Record<string, IntakeReminderEntry> = {};
|
||||
for (const [key, entry] of Object.entries(reminders)) {
|
||||
const timestamp = parseInt(key.split(":").pop() || "0", 10);
|
||||
if (timestamp >= todayStartMs) {
|
||||
cleaned[key] = entry;
|
||||
}
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
@@ -3,123 +3,111 @@
|
||||
* Exported separately to allow testing without triggering server start.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
import { existsSync, mkdirSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import type { CookieSerializeOptions } from "@fastify/cookie";
|
||||
|
||||
/**
|
||||
* Parse comma-separated CORS origins string
|
||||
*/
|
||||
export function parseCorsOrigins(originsStr: string): string[] {
|
||||
return originsStr
|
||||
.split(",")
|
||||
.map((o) => o.trim())
|
||||
.filter((o) => o.length > 0);
|
||||
return originsStr
|
||||
.split(",")
|
||||
.map((o) => o.trim())
|
||||
.filter((o) => o.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build base cookie options for access token
|
||||
*/
|
||||
export function buildBaseCookieOptions(
|
||||
accessTtlMinutes: number,
|
||||
isProduction: boolean
|
||||
): CookieSerializeOptions {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: accessTtlMinutes * 60, // Convert minutes to seconds
|
||||
};
|
||||
export function buildBaseCookieOptions(accessTtlMinutes: number, isProduction: boolean): CookieSerializeOptions {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: accessTtlMinutes * 60, // Convert minutes to seconds
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build refresh cookie options (extends base with longer TTL)
|
||||
*/
|
||||
export function buildRefreshCookieOptions(
|
||||
baseCookieOptions: CookieSerializeOptions,
|
||||
refreshTtlDays: number
|
||||
baseCookieOptions: CookieSerializeOptions,
|
||||
refreshTtlDays: number
|
||||
): CookieSerializeOptions {
|
||||
return {
|
||||
...baseCookieOptions,
|
||||
maxAge: refreshTtlDays * 24 * 60 * 60, // Convert days to seconds
|
||||
};
|
||||
return {
|
||||
...baseCookieOptions,
|
||||
maxAge: refreshTtlDays * 24 * 60 * 60, // Convert days to seconds
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build complete app configuration object
|
||||
*/
|
||||
export interface AppConfigOptions {
|
||||
jwtSecret?: string;
|
||||
refreshSecret?: string;
|
||||
accessTtlMinutes: number;
|
||||
refreshTtlDays: number;
|
||||
isProduction: boolean;
|
||||
jwtSecret?: string;
|
||||
refreshSecret?: string;
|
||||
accessTtlMinutes: number;
|
||||
refreshTtlDays: number;
|
||||
isProduction: boolean;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
accessSecret: string;
|
||||
refreshSecret: string;
|
||||
accessTtl: number;
|
||||
refreshTtl: number;
|
||||
cookieOptions: CookieSerializeOptions;
|
||||
refreshCookieOptions: CookieSerializeOptions;
|
||||
accessSecret: string;
|
||||
refreshSecret: string;
|
||||
accessTtl: number;
|
||||
refreshTtl: number;
|
||||
cookieOptions: CookieSerializeOptions;
|
||||
refreshCookieOptions: CookieSerializeOptions;
|
||||
}
|
||||
|
||||
export function buildAppConfig(options: AppConfigOptions): AppConfig {
|
||||
const cookieOptions = buildBaseCookieOptions(
|
||||
options.accessTtlMinutes,
|
||||
options.isProduction
|
||||
);
|
||||
const refreshCookieOptions = buildRefreshCookieOptions(
|
||||
cookieOptions,
|
||||
options.refreshTtlDays
|
||||
);
|
||||
const cookieOptions = buildBaseCookieOptions(options.accessTtlMinutes, options.isProduction);
|
||||
const refreshCookieOptions = buildRefreshCookieOptions(cookieOptions, options.refreshTtlDays);
|
||||
|
||||
return {
|
||||
accessSecret: options.jwtSecret || "",
|
||||
refreshSecret: options.refreshSecret || "",
|
||||
accessTtl: options.accessTtlMinutes,
|
||||
refreshTtl: options.refreshTtlDays,
|
||||
cookieOptions,
|
||||
refreshCookieOptions,
|
||||
};
|
||||
return {
|
||||
accessSecret: options.jwtSecret || "",
|
||||
refreshSecret: options.refreshSecret || "",
|
||||
accessTtl: options.accessTtlMinutes,
|
||||
refreshTtl: options.refreshTtlDays,
|
||||
cookieOptions,
|
||||
refreshCookieOptions,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure images directory exists
|
||||
*/
|
||||
export function ensureImagesDirectory(cwd?: string): string {
|
||||
const basePath = cwd || process.cwd();
|
||||
const imagesDir = resolve(basePath, "data/images");
|
||||
if (!existsSync(imagesDir)) {
|
||||
mkdirSync(imagesDir, { recursive: true });
|
||||
}
|
||||
return imagesDir;
|
||||
const basePath = cwd || process.cwd();
|
||||
const imagesDir = resolve(basePath, "data/images");
|
||||
if (!existsSync(imagesDir)) {
|
||||
mkdirSync(imagesDir, { recursive: true });
|
||||
}
|
||||
return imagesDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get JWT configuration based on auth enabled status
|
||||
*/
|
||||
export interface JwtConfig {
|
||||
secret: string;
|
||||
cookie: {
|
||||
cookieName: string;
|
||||
signed: boolean;
|
||||
};
|
||||
secret: string;
|
||||
cookie: {
|
||||
cookieName: string;
|
||||
signed: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function getJwtConfig(authEnabled: boolean, jwtSecret?: string): JwtConfig {
|
||||
const effectiveSecret =
|
||||
authEnabled && jwtSecret
|
||||
? jwtSecret
|
||||
: "auth-disabled-no-secret-needed";
|
||||
const effectiveSecret = authEnabled && jwtSecret ? jwtSecret : "auth-disabled-no-secret-needed";
|
||||
|
||||
return {
|
||||
secret: effectiveSecret,
|
||||
cookie: {
|
||||
cookieName: "access_token",
|
||||
signed: false,
|
||||
},
|
||||
};
|
||||
return {
|
||||
secret: effectiveSecret,
|
||||
cookie: {
|
||||
cookieName: "access_token",
|
||||
signed: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.12/schema.json",
|
||||
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
||||
"files": {
|
||||
"includes": ["backend/src/**/*.ts", "frontend/src/**/*.ts", "frontend/src/**/*.tsx", "frontend/src/**/*.css"]
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"complexity": {
|
||||
"noForEach": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "warn",
|
||||
"useIterableCallbackReturn": "off",
|
||||
"noImplicitAnyLet": "warn",
|
||||
"noArrayIndexKey": "warn",
|
||||
"noAssignInExpressions": "off"
|
||||
},
|
||||
"style": {
|
||||
"noNonNullAssertion": "off",
|
||||
"useConst": "error",
|
||||
"noParameterAssign": "off"
|
||||
},
|
||||
"correctness": {
|
||||
"noUnusedVariables": "warn",
|
||||
"noUnusedImports": "warn",
|
||||
"noUnusedFunctionParameters": "warn",
|
||||
"useExhaustiveDependencies": "warn"
|
||||
},
|
||||
"a11y": {
|
||||
"useKeyWithClickEvents": "warn",
|
||||
"noSvgWithoutTitle": "off",
|
||||
"noStaticElementInteractions": "off",
|
||||
"useButtonType": "off",
|
||||
"noLabelWithoutControl": "warn"
|
||||
}
|
||||
}
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "tab",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 120
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double",
|
||||
"semicolons": "always",
|
||||
"trailingCommas": "es5"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+166
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "medassist-ng-frontend",
|
||||
"version": "1.4.1",
|
||||
"version": "1.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "medassist-ng-frontend",
|
||||
"version": "1.4.1",
|
||||
"version": "1.6.0",
|
||||
"dependencies": {
|
||||
"i18next": "^24.2.2",
|
||||
"i18next-browser-languagedetector": "^8.0.4",
|
||||
@@ -17,6 +17,7 @@
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.12",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
@@ -401,6 +402,169 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/biome": {
|
||||
"version": "2.3.12",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.12.tgz",
|
||||
"integrity": "sha512-AR7h4aSlAvXj7TAajW/V12BOw2EiS0AqZWV5dGozf4nlLoUF/ifvD0+YgKSskT0ylA6dY1A8AwgP8kZ6yaCQnA==",
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"bin": {
|
||||
"biome": "bin/biome"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/biome"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@biomejs/cli-darwin-arm64": "2.3.12",
|
||||
"@biomejs/cli-darwin-x64": "2.3.12",
|
||||
"@biomejs/cli-linux-arm64": "2.3.12",
|
||||
"@biomejs/cli-linux-arm64-musl": "2.3.12",
|
||||
"@biomejs/cli-linux-x64": "2.3.12",
|
||||
"@biomejs/cli-linux-x64-musl": "2.3.12",
|
||||
"@biomejs/cli-win32-arm64": "2.3.12",
|
||||
"@biomejs/cli-win32-x64": "2.3.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-darwin-arm64": {
|
||||
"version": "2.3.12",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.12.tgz",
|
||||
"integrity": "sha512-cO6fn+KiMBemva6EARDLQBxeyvLzgidaFRJi8G7OeRqz54kWK0E+uSjgFaiHlc3DZYoa0+1UFE8mDxozpc9ieg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-darwin-x64": {
|
||||
"version": "2.3.12",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.12.tgz",
|
||||
"integrity": "sha512-/fiF/qmudKwSdvmSrSe/gOTkW77mHHkH8Iy7YC2rmpLuk27kbaUOPa7kPiH5l+3lJzTUfU/t6x1OuIq/7SGtxg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-arm64": {
|
||||
"version": "2.3.12",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.12.tgz",
|
||||
"integrity": "sha512-nbOsuQROa3DLla5vvsTZg+T5WVPGi9/vYxETm9BOuLHBJN3oWQIg3MIkE2OfL18df1ZtNkqXkH6Yg9mdTPem7A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
||||
"version": "2.3.12",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.12.tgz",
|
||||
"integrity": "sha512-aqkeSf7IH+wkzFpKeDVPSXy9uDjxtLpYA6yzkYsY+tVjwFFirSuajHDI3ul8en90XNs1NA0n8kgBrjwRi5JeyA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-x64": {
|
||||
"version": "2.3.12",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.12.tgz",
|
||||
"integrity": "sha512-CQtqrJ+qEEI8tgRSTjjzk6wJAwfH3wQlkIGsM5dlecfRZaoT+XCms/mf7G4kWNexrke6mnkRzNy6w8ebV177ow==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-x64-musl": {
|
||||
"version": "2.3.12",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.12.tgz",
|
||||
"integrity": "sha512-kVGWtupRRsOjvw47YFkk5mLiAdpCPMWBo1jOwAzh+juDpUb2sWarIp+iq+CPL1Wt0LLZnYtP7hH5kD6fskcxmg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-win32-arm64": {
|
||||
"version": "2.3.12",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.12.tgz",
|
||||
"integrity": "sha512-Re4I7UnOoyE4kHMqpgtG6UvSBGBbbtvsOvBROgCCoH7EgANN6plSQhvo2W7OCITvTp7gD6oZOyZy72lUdXjqZg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-win32-x64": {
|
||||
"version": "2.3.12",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.12.tgz",
|
||||
"integrity": "sha512-qqGVWqNNek0KikwPZlOIoxtXgsNGsX+rgdEzgw82Re8nF02W+E2WokaQhpF5TdBh/D/RQ3TLppH+otp6ztN0lw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
{
|
||||
"name": "medassist-ng-frontend",
|
||||
"private": true,
|
||||
"version": "1.5.0",
|
||||
"version": "1.7.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "echo 'add lint config'",
|
||||
"lint": "npx biome check .",
|
||||
"lint:fix": "npx biome check --write .",
|
||||
"format": "npx biome format --write .",
|
||||
"check": "npx biome check . && tsc --noEmit",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -21,6 +25,7 @@
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.12",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
|
||||
+157
-76
@@ -1,15 +1,23 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Routes, Route, Navigate } from "react-router-dom";
|
||||
import { AuthProvider, useAuth, AuthPage } from "./components/Auth";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import {
|
||||
AboutModal,
|
||||
Lightbox,
|
||||
MedDetailModal,
|
||||
ProfileModal,
|
||||
ShareDialog,
|
||||
SharedSchedule,
|
||||
UserFilterModal,
|
||||
} from "./components";
|
||||
import { AppHeader } from "./components/AppHeader";
|
||||
import { SharedSchedule, Lightbox, MedDetailModal, UserFilterModal, ShareDialog, ProfileModal, AboutModal } from "./components";
|
||||
import { AppProvider, useAppContext } from "./context";
|
||||
import { PlannerPage, SchedulePage, SettingsPage, DashboardPage, MedicationsPage } from "./pages";
|
||||
import { AuthPage, AuthProvider, useAuth } from "./components/Auth";
|
||||
import { AppProvider, UnsavedChangesProvider, useAppContext } from "./context";
|
||||
import { DashboardPage, MedicationsPage, PlannerPage, SchedulePage, SettingsPage } from "./pages";
|
||||
|
||||
// Vite injects this at build time from package.json
|
||||
declare const __APP_VERSION__: string;
|
||||
export const FRONTEND_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'unknown';
|
||||
const GITHUB_REPO = 'DanielVolz/medassist-ng';
|
||||
export const FRONTEND_VERSION = typeof __APP_VERSION__ !== "undefined" ? __APP_VERSION__ : "unknown";
|
||||
const GITHUB_REPO = "DanielVolz/medassist-ng";
|
||||
export const GITHUB_URL = `https://github.com/${GITHUB_REPO}`;
|
||||
|
||||
// =============================================================================
|
||||
@@ -36,7 +44,7 @@ function AppRouter() {
|
||||
return (
|
||||
<div className="auth-container">
|
||||
<div className="auth-card" style={{ textAlign: "center" }}>
|
||||
<h1 className="auth-title">💊 MedAssist</h1>
|
||||
<h1 className="auth-title">💊 MedAssist-ng</h1>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -48,19 +56,16 @@ function AppRouter() {
|
||||
return (
|
||||
<div className="auth-container">
|
||||
<div className="auth-card" style={{ textAlign: "center" }}>
|
||||
<h1 className="auth-title">💊 MedAssist</h1>
|
||||
<h1 className="auth-title">💊 MedAssist-ng</h1>
|
||||
<div className="auth-error" style={{ marginBottom: "1rem" }}>
|
||||
<strong>Connection Error</strong><br />
|
||||
<strong>Connection Error</strong>
|
||||
<br />
|
||||
{authError}
|
||||
</div>
|
||||
<p style={{ fontSize: "0.9rem", color: "var(--text-muted)" }}>
|
||||
Please check if the server is running and try again.
|
||||
</p>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => window.location.reload()}
|
||||
style={{ marginTop: "1rem" }}
|
||||
>
|
||||
<button className="btn btn-primary" onClick={() => window.location.reload()} style={{ marginTop: "1rem" }}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
@@ -73,7 +78,7 @@ function AppRouter() {
|
||||
return (
|
||||
<div className="auth-container">
|
||||
<div className="auth-card" style={{ textAlign: "center" }}>
|
||||
<h1 className="auth-title">💊 MedAssist</h1>
|
||||
<h1 className="auth-title">💊 MedAssist-ng</h1>
|
||||
<p>Initializing...</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,9 +99,11 @@ function AppRouter() {
|
||||
|
||||
// Auth disabled or user is logged in - show main app
|
||||
return (
|
||||
<AppProvider>
|
||||
<AppContent />
|
||||
</AppProvider>
|
||||
<UnsavedChangesProvider>
|
||||
<AppProvider>
|
||||
<AppContent />
|
||||
</AppProvider>
|
||||
</UnsavedChangesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -109,36 +116,78 @@ function AppContent() {
|
||||
const ctx = useAppContext();
|
||||
const {
|
||||
// Medications
|
||||
meds, loadMeds,
|
||||
meds,
|
||||
loadMeds,
|
||||
// Settings
|
||||
settings,
|
||||
// Refill
|
||||
showRefillModal, setShowRefillModal, refillPacks, setRefillPacks, refillLoose, setRefillLoose,
|
||||
refillSaving, refillHistory, refillHistoryExpanded, setRefillHistoryExpanded,
|
||||
showEditStockModal, setShowEditStockModal, editStockFullBlisters, setEditStockFullBlisters,
|
||||
editStockPartialBlisterPills, setEditStockPartialBlisterPills, editStockSaving,
|
||||
openRefillModal, closeRefillModal, openEditStockModal, closeEditStockModal,
|
||||
showRefillModal,
|
||||
setShowRefillModal,
|
||||
refillPacks,
|
||||
setRefillPacks,
|
||||
refillLoose,
|
||||
setRefillLoose,
|
||||
refillSaving,
|
||||
refillHistory,
|
||||
refillHistoryExpanded,
|
||||
setRefillHistoryExpanded,
|
||||
showEditStockModal,
|
||||
setShowEditStockModal,
|
||||
editStockFullBlisters,
|
||||
setEditStockFullBlisters,
|
||||
editStockPartialBlisterPills,
|
||||
setEditStockPartialBlisterPills,
|
||||
editStockSaving,
|
||||
openRefillModal,
|
||||
closeRefillModal,
|
||||
openEditStockModal,
|
||||
closeEditStockModal,
|
||||
// Share
|
||||
showShareDialog, sharePeople, shareSelectedPerson, setShareSelectedPerson,
|
||||
shareSelectedDays, setShareSelectedDays, shareGenerating, shareLink, setShareLink,
|
||||
shareCopied, setShareCopied, generateShareLink, copyShareLink, closeShareDialog, resetShareDialogState,
|
||||
showShareDialog,
|
||||
sharePeople,
|
||||
shareSelectedPerson,
|
||||
setShareSelectedPerson,
|
||||
shareSelectedDays,
|
||||
setShareSelectedDays,
|
||||
shareGenerating,
|
||||
shareLink,
|
||||
setShareLink,
|
||||
shareCopied,
|
||||
setShareCopied,
|
||||
generateShareLink,
|
||||
copyShareLink,
|
||||
closeShareDialog,
|
||||
resetShareDialogState,
|
||||
// Computed
|
||||
coverage,
|
||||
// Modal state
|
||||
selectedMed, setSelectedMed, showImageLightbox, setShowImageLightbox,
|
||||
scheduleLightboxImage, setScheduleLightboxImage, selectedUser, setSelectedUser,
|
||||
selectedMed,
|
||||
setSelectedMed,
|
||||
showImageLightbox,
|
||||
setShowImageLightbox,
|
||||
scheduleLightboxImage,
|
||||
setScheduleLightboxImage,
|
||||
selectedUser,
|
||||
setSelectedUser,
|
||||
// Modal helpers
|
||||
openMedDetail, closeMedDetail, openImageLightbox, closeImageLightbox,
|
||||
openScheduleLightbox, closeScheduleLightbox, closeUserFilter,
|
||||
openMedDetail,
|
||||
closeMedDetail,
|
||||
openImageLightbox,
|
||||
closeImageLightbox,
|
||||
closeScheduleLightbox,
|
||||
closeUserFilter,
|
||||
} = ctx;
|
||||
|
||||
|
||||
// Wrapper to pass meds to openShareDialog
|
||||
const openShareDialog = () => ctx.openShareDialog();
|
||||
|
||||
const _openShareDialog = () => ctx.openShareDialog();
|
||||
|
||||
// Local-only state (not shared across components)
|
||||
const [showProfile, setShowProfile] = useState(false);
|
||||
const [showAbout, setShowAbout] = useState(false);
|
||||
|
||||
// Get centralized stockThresholds from context
|
||||
const { stockThresholds } = ctx;
|
||||
|
||||
// Close modal on Escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
@@ -167,7 +216,26 @@ function AppContent() {
|
||||
};
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [selectedMed, showImageLightbox, scheduleLightboxImage, selectedUser, showProfile, showAbout, showShareDialog, showRefillModal, showEditStockModal]);
|
||||
}, [
|
||||
selectedMed,
|
||||
showImageLightbox,
|
||||
scheduleLightboxImage,
|
||||
selectedUser,
|
||||
showProfile,
|
||||
showAbout,
|
||||
showShareDialog,
|
||||
showRefillModal,
|
||||
showEditStockModal,
|
||||
closeAbout,
|
||||
closeEditStockModal,
|
||||
closeImageLightbox,
|
||||
closeMedDetail,
|
||||
closeProfile,
|
||||
closeRefillModal,
|
||||
closeScheduleLightbox,
|
||||
closeShareDialog,
|
||||
closeUserFilter,
|
||||
]);
|
||||
|
||||
// Handle browser back button to close modals (in priority order)
|
||||
useEffect(() => {
|
||||
@@ -195,41 +263,58 @@ function AppContent() {
|
||||
setSelectedMed(null);
|
||||
}
|
||||
};
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, [selectedMed, showImageLightbox, scheduleLightboxImage, selectedUser, showProfile, showAbout, showShareDialog, showRefillModal, showEditStockModal]);
|
||||
window.addEventListener("popstate", handlePopState);
|
||||
return () => window.removeEventListener("popstate", handlePopState);
|
||||
}, [
|
||||
selectedMed,
|
||||
showImageLightbox,
|
||||
scheduleLightboxImage,
|
||||
selectedUser,
|
||||
showProfile,
|
||||
showAbout,
|
||||
showShareDialog,
|
||||
showRefillModal,
|
||||
showEditStockModal,
|
||||
resetShareDialogState,
|
||||
setScheduleLightboxImage,
|
||||
setSelectedMed,
|
||||
setSelectedUser,
|
||||
setShowEditStockModal,
|
||||
setShowImageLightbox,
|
||||
setShowRefillModal,
|
||||
]);
|
||||
|
||||
// Close tooltips on scroll/touch (for mobile)
|
||||
useEffect(() => {
|
||||
const closeAllTooltips = () => {
|
||||
document.querySelectorAll('.info-tooltip.tooltip-active').forEach(el => {
|
||||
el.classList.remove('tooltip-active');
|
||||
document.querySelectorAll(".info-tooltip.tooltip-active").forEach((el) => {
|
||||
el.classList.remove("tooltip-active");
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const handleTooltipClick = (e: Event) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.classList.contains('info-tooltip')) {
|
||||
if (target.classList.contains("info-tooltip")) {
|
||||
// Close other tooltips first
|
||||
closeAllTooltips();
|
||||
// Toggle this one
|
||||
target.classList.add('tooltip-active');
|
||||
target.classList.add("tooltip-active");
|
||||
} else {
|
||||
closeAllTooltips();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleTouchMove = () => {
|
||||
closeAllTooltips();
|
||||
};
|
||||
|
||||
document.addEventListener('click', handleTooltipClick, { capture: true });
|
||||
document.addEventListener('touchmove', handleTouchMove, { passive: true });
|
||||
document.addEventListener('scroll', handleTouchMove, { passive: true });
|
||||
|
||||
document.addEventListener("click", handleTooltipClick, { capture: true });
|
||||
document.addEventListener("touchmove", handleTouchMove, { passive: true });
|
||||
document.addEventListener("scroll", handleTouchMove, { passive: true });
|
||||
return () => {
|
||||
document.removeEventListener('click', handleTooltipClick, { capture: true });
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
document.removeEventListener('scroll', handleTouchMove);
|
||||
document.removeEventListener("click", handleTooltipClick, { capture: true });
|
||||
document.removeEventListener("touchmove", handleTouchMove);
|
||||
document.removeEventListener("scroll", handleTouchMove);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -238,35 +323,36 @@ function AppContent() {
|
||||
const isModalOpen = selectedMed || selectedUser || showProfile || showAbout || showShareDialog;
|
||||
if (isModalOpen) {
|
||||
const scrollY = window.scrollY;
|
||||
document.body.classList.add('modal-open');
|
||||
document.body.classList.add("modal-open");
|
||||
document.body.style.top = `-${scrollY}px`;
|
||||
} else {
|
||||
const scrollY = document.body.style.top;
|
||||
document.body.classList.remove('modal-open');
|
||||
document.body.style.top = '';
|
||||
document.body.classList.remove("modal-open");
|
||||
document.body.style.top = "";
|
||||
if (scrollY) {
|
||||
window.scrollTo(0, parseInt(scrollY || '0', 10) * -1);
|
||||
window.scrollTo(0, parseInt(scrollY || "0", 10) * -1);
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
document.body.classList.remove('modal-open');
|
||||
document.body.style.top = '';
|
||||
document.body.classList.remove("modal-open");
|
||||
document.body.style.top = "";
|
||||
};
|
||||
}, [selectedMed, selectedUser, showProfile, showAbout, showShareDialog]);
|
||||
|
||||
// Update selectedMed when meds change (e.g., after refill)
|
||||
useEffect(() => {
|
||||
if (selectedMed) {
|
||||
const updated = meds.find(m => m.id === selectedMed.id);
|
||||
if (updated && (
|
||||
updated.packCount !== selectedMed.packCount ||
|
||||
updated.looseTablets !== selectedMed.looseTablets ||
|
||||
updated.updatedAt !== selectedMed.updatedAt
|
||||
)) {
|
||||
const updated = meds.find((m) => m.id === selectedMed.id);
|
||||
if (
|
||||
updated &&
|
||||
(updated.packCount !== selectedMed.packCount ||
|
||||
updated.looseTablets !== selectedMed.looseTablets ||
|
||||
updated.updatedAt !== selectedMed.updatedAt)
|
||||
) {
|
||||
setSelectedMed(updated);
|
||||
}
|
||||
}
|
||||
}, [meds, selectedMed]);
|
||||
}, [meds, selectedMed, setSelectedMed]);
|
||||
|
||||
const handleSubmitStockCorrection = async (medId: number) => {
|
||||
if (!selectedMed) return;
|
||||
@@ -277,7 +363,7 @@ function AppContent() {
|
||||
const handleSubmitRefill = async (medId: number) => {
|
||||
await ctx.submitRefill(medId, null, () => {}, loadMeds);
|
||||
};
|
||||
|
||||
|
||||
// Wrapper for openEditStockModal (provides selectedMed and coverage)
|
||||
const handleOpenEditStockModal = () => {
|
||||
if (selectedMed) {
|
||||
@@ -287,7 +373,7 @@ function AppContent() {
|
||||
|
||||
function openProfile() {
|
||||
setShowProfile(true);
|
||||
window.history.pushState({ modal: 'profile' }, '');
|
||||
window.history.pushState({ modal: "profile" }, "");
|
||||
}
|
||||
function closeProfile() {
|
||||
if (showProfile) {
|
||||
@@ -297,7 +383,7 @@ function AppContent() {
|
||||
|
||||
function openAbout() {
|
||||
setShowAbout(true);
|
||||
window.history.pushState({ modal: 'about' }, '');
|
||||
window.history.pushState({ modal: "about" }, "");
|
||||
}
|
||||
function closeAbout() {
|
||||
if (showAbout) {
|
||||
@@ -334,7 +420,7 @@ function AppContent() {
|
||||
<MedDetailModal
|
||||
selectedMed={selectedMed}
|
||||
coverage={coverage}
|
||||
settings={settings}
|
||||
settings={stockThresholds}
|
||||
showImageLightbox={showImageLightbox}
|
||||
showRefillModal={showRefillModal}
|
||||
showEditStockModal={showEditStockModal}
|
||||
@@ -367,7 +453,7 @@ function AppContent() {
|
||||
selectedUser={selectedUser}
|
||||
meds={meds}
|
||||
coverage={coverage}
|
||||
settings={settings}
|
||||
settings={stockThresholds}
|
||||
onClose={closeUserFilter}
|
||||
onOpenMedDetail={openMedDetail}
|
||||
/>
|
||||
@@ -392,13 +478,8 @@ function AppContent() {
|
||||
|
||||
{/* Schedule Lightbox - for clicking medication images in schedule */}
|
||||
{scheduleLightboxImage && (
|
||||
<Lightbox
|
||||
src={scheduleLightboxImage}
|
||||
alt="Medication"
|
||||
onClose={closeScheduleLightbox}
|
||||
/>
|
||||
<Lightbox src={scheduleLightboxImage} alt="Medication" onClose={closeScheduleLightbox} />
|
||||
)}
|
||||
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FRONTEND_VERSION, GITHUB_URL } from '../App';
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FRONTEND_VERSION, GITHUB_URL } from "../App";
|
||||
|
||||
interface UpdateCheckResult {
|
||||
status: 'checking' | 'up-to-date' | 'update-available' | 'error';
|
||||
status: "checking" | "up-to-date" | "update-available" | "error";
|
||||
latestVersion?: string;
|
||||
lastChecked?: string;
|
||||
}
|
||||
@@ -23,17 +23,17 @@ export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
|
||||
if (!isOpen) return;
|
||||
|
||||
// Fetch backend version
|
||||
fetch('/api/health')
|
||||
.then(res => res.json())
|
||||
.then(data => setBackendVersion(data.version || 'unknown'))
|
||||
.catch(() => setBackendVersion('unknown'));
|
||||
fetch("/api/health")
|
||||
.then((res) => res.json())
|
||||
.then((data) => setBackendVersion(data.version || "unknown"))
|
||||
.catch(() => setBackendVersion("unknown"));
|
||||
|
||||
// Load cached update check result
|
||||
const cached = sessionStorage.getItem('updateCheckResult');
|
||||
const cached = sessionStorage.getItem("updateCheckResult");
|
||||
if (cached) {
|
||||
try {
|
||||
const parsed = JSON.parse(cached);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
if (parsed && typeof parsed === "object") {
|
||||
setUpdateCheckResult(parsed);
|
||||
}
|
||||
} catch {
|
||||
@@ -43,24 +43,24 @@ export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
|
||||
}, [isOpen]);
|
||||
|
||||
async function checkForUpdates() {
|
||||
setUpdateCheckResult({ status: 'checking' });
|
||||
setUpdateCheckResult({ status: "checking" });
|
||||
try {
|
||||
const res = await fetch(`https://api.github.com/repos/DanielVolz/medassist-ng/releases/latest`);
|
||||
if (!res.ok) throw new Error('Failed to fetch');
|
||||
if (!res.ok) throw new Error("Failed to fetch");
|
||||
const data = await res.json();
|
||||
const latestVersion = (data.tag_name || '').replace(/^v/, '');
|
||||
const currentVersion = FRONTEND_VERSION.replace(/^v/, '');
|
||||
const latestVersion = (data.tag_name || "").replace(/^v/, "");
|
||||
const currentVersion = FRONTEND_VERSION.replace(/^v/, "");
|
||||
const isUpToDate = latestVersion === currentVersion;
|
||||
const result: UpdateCheckResult = {
|
||||
status: isUpToDate ? 'up-to-date' : 'update-available',
|
||||
status: isUpToDate ? "up-to-date" : "update-available",
|
||||
latestVersion,
|
||||
lastChecked: new Date().toISOString()
|
||||
lastChecked: new Date().toISOString(),
|
||||
};
|
||||
setUpdateCheckResult(result);
|
||||
// Cache the result
|
||||
sessionStorage.setItem('updateCheckResult', JSON.stringify(result));
|
||||
sessionStorage.setItem("updateCheckResult", JSON.stringify(result));
|
||||
} catch {
|
||||
setUpdateCheckResult({ status: 'error' });
|
||||
setUpdateCheckResult({ status: "error" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,66 +69,78 @@ export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content about-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
<div className="about-header">
|
||||
<div className="about-logo">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M19.5 12c0 4.14-3.36 7.5-7.5 7.5S4.5 16.14 4.5 12 7.86 4.5 12 4.5s7.5 3.36 7.5 7.5z"/>
|
||||
<path d="M12 8v4l2.5 2.5"/>
|
||||
<path d="M9 2h6M12 2v2"/>
|
||||
<path d="M19.5 12c0 4.14-3.36 7.5-7.5 7.5S4.5 16.14 4.5 12 7.86 4.5 12 4.5s7.5 3.36 7.5 7.5z" />
|
||||
<path d="M12 8v4l2.5 2.5" />
|
||||
<path d="M9 2h6M12 2v2" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2>{t('about.appName', 'MedAssist')}</h2>
|
||||
<p className="about-tagline">{t('about.description', 'Personal medication tracking and reminder app')}</p>
|
||||
<h2>{t("about.appName", "MedAssist-ng")}</h2>
|
||||
<p className="about-tagline">{t("about.description", "Personal medication tracking and reminder app")}</p>
|
||||
</div>
|
||||
<div className="about-versions">
|
||||
<div className="about-version-row">
|
||||
<span className="about-version-label">{t('about.frontendVersion', 'Frontend')}</span>
|
||||
<span className="about-version-label">{t("about.frontendVersion", "Frontend")}</span>
|
||||
<span className="about-version-value">{FRONTEND_VERSION}</span>
|
||||
</div>
|
||||
<div className="about-version-row">
|
||||
<span className="about-version-label">{t('about.backendVersion', 'Backend')}</span>
|
||||
<span className="about-version-value">{backendVersion || '...'}</span>
|
||||
<span className="about-version-label">{t("about.backendVersion", "Backend")}</span>
|
||||
<span className="about-version-value">{backendVersion || "..."}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="about-update-section">
|
||||
<button className="about-update-btn" onClick={checkForUpdates} disabled={updateCheckResult?.status === 'checking'}>
|
||||
{updateCheckResult?.status === 'checking' ? (
|
||||
<button
|
||||
className="about-update-btn"
|
||||
onClick={checkForUpdates}
|
||||
disabled={updateCheckResult?.status === "checking"}
|
||||
>
|
||||
{updateCheckResult?.status === "checking" ? (
|
||||
<>
|
||||
<span className="spinner-small"></span>
|
||||
{t('about.checking', 'Checking...')}
|
||||
{t("about.checking", "Checking...")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
|
||||
<path d="M3 3v5h5"/>
|
||||
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/>
|
||||
<path d="M16 16h5v5"/>
|
||||
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
|
||||
<path d="M16 16h5v5" />
|
||||
</svg>
|
||||
{t('about.checkForUpdates', 'Check for Updates')}
|
||||
{t("about.checkForUpdates", "Check for Updates")}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{updateCheckResult && updateCheckResult.status !== 'checking' && (
|
||||
{updateCheckResult && updateCheckResult.status !== "checking" && (
|
||||
<div className={`about-update-result ${updateCheckResult.status}`}>
|
||||
{updateCheckResult.status === 'up-to-date' && (
|
||||
<span className="update-status-text">✓ {t('about.upToDate', 'You are up to date!')}</span>
|
||||
{updateCheckResult.status === "up-to-date" && (
|
||||
<span className="update-status-text">✓ {t("about.upToDate", "You are up to date!")}</span>
|
||||
)}
|
||||
{updateCheckResult.status === 'update-available' && (
|
||||
{updateCheckResult.status === "update-available" && (
|
||||
<span className="update-status-text">
|
||||
⬆ {t('about.updateAvailable', 'Update available')}: <strong>v{updateCheckResult.latestVersion}</strong>
|
||||
<a href={`${GITHUB_URL}/releases/latest`} target="_blank" rel="noopener noreferrer" className="update-download-link">
|
||||
{t('about.downloadUpdate', 'Download')}
|
||||
⬆ {t("about.updateAvailable", "Update available")}:{" "}
|
||||
<strong>v{updateCheckResult.latestVersion}</strong>
|
||||
<a
|
||||
href={`${GITHUB_URL}/releases/latest`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="update-download-link"
|
||||
>
|
||||
{t("about.downloadUpdate", "Download")}
|
||||
</a>
|
||||
</span>
|
||||
)}
|
||||
{updateCheckResult.status === 'error' && (
|
||||
<span className="update-status-text">⚠ {t('about.checkFailed', 'Could not check for updates')}</span>
|
||||
{updateCheckResult.status === "error" && (
|
||||
<span className="update-status-text">⚠ {t("about.checkFailed", "Could not check for updates")}</span>
|
||||
)}
|
||||
{updateCheckResult.lastChecked && (
|
||||
<span className="update-last-checked">
|
||||
{t('about.lastChecked', 'Last checked')}: {new Date(updateCheckResult.lastChecked).toLocaleString()}
|
||||
{t("about.lastChecked", "Last checked")}: {new Date(updateCheckResult.lastChecked).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -137,14 +149,16 @@ export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
|
||||
<div className="about-links">
|
||||
<a href={GITHUB_URL} target="_blank" rel="noopener noreferrer" className="about-link">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
{t('about.viewOnGitHub', 'View on GitHub')}
|
||||
{t("about.viewOnGitHub", "View on GitHub")}
|
||||
</a>
|
||||
</div>
|
||||
<div className="about-footer">
|
||||
<p className="about-copyright">{t('about.copyright', '© {{year}} Daniel Volz', { year: new Date().getFullYear() })}</p>
|
||||
<p className="about-license">{t('about.license', 'GPL-3.0 License')}</p>
|
||||
<p className="about-copyright">
|
||||
{t("about.copyright", "© {{year}} Daniel Volz", { year: new Date().getFullYear() })}
|
||||
</p>
|
||||
<p className="about-license">{t("about.license", "GPL-3.0 License")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* AppHeader - Main application header with navigation and user menu
|
||||
*/
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "./Auth";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { useUnsavedChanges } from "../context";
|
||||
import { useTheme } from "../hooks";
|
||||
import { useAuth } from "./Auth";
|
||||
|
||||
interface AppHeaderProps {
|
||||
onOpenProfile: () => void;
|
||||
@@ -19,7 +20,15 @@ export function AppHeader({ onOpenProfile, onOpenAbout }: AppHeaderProps) {
|
||||
const currentPath = location.pathname;
|
||||
const { user, authState, logout } = useAuth();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
const { confirmNavigation } = useUnsavedChanges();
|
||||
|
||||
// Safe navigation that checks for unsaved changes first
|
||||
const safeNavigate = async (path: string) => {
|
||||
if (await confirmNavigation()) {
|
||||
navigate(path);
|
||||
}
|
||||
};
|
||||
|
||||
// User dropdown state (for mobile click-based behavior)
|
||||
const [userDropdownOpen, setUserDropdownOpen] = useState(false);
|
||||
|
||||
@@ -28,7 +37,7 @@ export function AppHeader({ onOpenProfile, onOpenAbout }: AppHeaderProps) {
|
||||
if (!userDropdownOpen) return;
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('.user-menu')) {
|
||||
if (!target.closest(".user-menu")) {
|
||||
setUserDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
@@ -38,12 +47,12 @@ export function AppHeader({ onOpenProfile, onOpenAbout }: AppHeaderProps) {
|
||||
|
||||
// Page titles based on current route
|
||||
const pageInfo = {
|
||||
"/dashboard": { eyebrow: t('header.eyebrow.overview'), title: t('nav.dashboard') },
|
||||
"/medications": { eyebrow: t('header.eyebrow.inventory'), title: t('nav.medications') },
|
||||
"/planner": { eyebrow: t('header.eyebrow.planner'), title: t('nav.planner') },
|
||||
"/settings": { eyebrow: t('header.eyebrow.settings'), title: t('nav.settings') },
|
||||
"/schedule": { eyebrow: t('header.eyebrow.schedule'), title: t('dashboard.schedules.title') },
|
||||
}[currentPath] || { eyebrow: t('header.eyebrow.overview'), title: t('nav.dashboard') };
|
||||
"/dashboard": { eyebrow: t("header.eyebrow.overview"), title: t("nav.dashboard") },
|
||||
"/medications": { eyebrow: t("header.eyebrow.inventory"), title: t("nav.medications") },
|
||||
"/planner": { eyebrow: t("header.eyebrow.planner"), title: t("nav.planner") },
|
||||
"/settings": { eyebrow: t("header.eyebrow.settings"), title: t("nav.settings") },
|
||||
"/schedule": { eyebrow: t("header.eyebrow.schedule"), title: t("dashboard.schedules.title") },
|
||||
}[currentPath] || { eyebrow: t("header.eyebrow.overview"), title: t("nav.dashboard") };
|
||||
|
||||
return (
|
||||
<header className="hero">
|
||||
@@ -56,19 +65,44 @@ export function AppHeader({ onOpenProfile, onOpenAbout }: AppHeaderProps) {
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<div className="tabs">
|
||||
<button className={currentPath === "/dashboard" || currentPath === "/" ? "pill primary" : "pill"} onClick={() => navigate("/dashboard")}>{t('nav.dashboard')}</button>
|
||||
<button className={currentPath === "/medications" ? "pill primary" : "pill"} onClick={() => navigate("/medications")}>{t('nav.medications')}</button>
|
||||
<button className={currentPath === "/planner" ? "pill primary" : "pill"} onClick={() => navigate("/planner")}>{t('nav.planner')}</button>
|
||||
<button
|
||||
className={currentPath === "/dashboard" || currentPath === "/" ? "pill primary" : "pill"}
|
||||
onClick={() => safeNavigate("/dashboard")}
|
||||
>
|
||||
{t("nav.dashboard")}
|
||||
</button>
|
||||
<button
|
||||
className={currentPath === "/medications" ? "pill primary" : "pill"}
|
||||
onClick={() => safeNavigate("/medications")}
|
||||
>
|
||||
{t("nav.medications")}
|
||||
</button>
|
||||
<button
|
||||
className={currentPath === "/planner" ? "pill primary" : "pill"}
|
||||
onClick={() => safeNavigate("/planner")}
|
||||
>
|
||||
{t("nav.planner")}
|
||||
</button>
|
||||
</div>
|
||||
{/* Settings button only shown when auth is disabled (no user dropdown available) */}
|
||||
{!authState?.authEnabled && (
|
||||
<button className={`icon-btn ${currentPath === "/settings" ? "active" : ""}`} onClick={() => navigate("/settings")} title={t('nav.settings')}>⚙️</button>
|
||||
<button
|
||||
className={`icon-btn ${currentPath === "/settings" ? "active" : ""}`}
|
||||
onClick={() => safeNavigate("/settings")}
|
||||
title={t("nav.settings")}
|
||||
>
|
||||
⚙️
|
||||
</button>
|
||||
)}
|
||||
<button className="icon-btn" onClick={toggleTheme} title={theme === "dark" ? t('tooltips.lightMode') : t('tooltips.darkMode')}>
|
||||
<button
|
||||
className="icon-btn"
|
||||
onClick={toggleTheme}
|
||||
title={theme === "dark" ? t("tooltips.lightMode") : t("tooltips.darkMode")}
|
||||
>
|
||||
{theme === "dark" ? "☀️" : "🌙"}
|
||||
</button>
|
||||
{authState?.authEnabled && user && (
|
||||
<div className={`user-menu ${userDropdownOpen ? 'open' : ''}`}>
|
||||
<div className={`user-menu ${userDropdownOpen ? "open" : ""}`}>
|
||||
<button className="user-menu-btn" onClick={() => setUserDropdownOpen(!userDropdownOpen)}>
|
||||
{user.avatarUrl ? (
|
||||
<img src={`/api/images/${user.avatarUrl}`} alt={user.username} className="user-avatar-img" />
|
||||
@@ -86,21 +120,59 @@ export function AppHeader({ onOpenProfile, onOpenAbout }: AppHeaderProps) {
|
||||
<span className="dropdown-username">{user.username}</span>
|
||||
</div>
|
||||
<div className="dropdown-menu">
|
||||
<button className="dropdown-item" onClick={() => { onOpenProfile(); setUserDropdownOpen(false); }}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
{t('auth.profile', 'Profile')}
|
||||
<button
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
onOpenProfile();
|
||||
setUserDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
{t("auth.profile", "Profile")}
|
||||
</button>
|
||||
<button className="dropdown-item" onClick={() => { navigate('/settings'); setUserDropdownOpen(false); }}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
{t('nav.settings', 'Settings')}
|
||||
<button
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
safeNavigate("/settings");
|
||||
setUserDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</svg>
|
||||
{t("nav.settings", "Settings")}
|
||||
</button>
|
||||
<button className="dropdown-item" onClick={() => { onOpenAbout(); setUserDropdownOpen(false); }}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
|
||||
{t('about.title', 'About')}
|
||||
<button
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
onOpenAbout();
|
||||
setUserDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 16v-4" />
|
||||
<path d="M12 8h.01" />
|
||||
</svg>
|
||||
{t("about.title", "About")}
|
||||
</button>
|
||||
<button className="dropdown-item danger" onClick={() => { logout(); setUserDropdownOpen(false); }}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||||
{t('auth.signOut', 'Sign Out')}
|
||||
<button
|
||||
className="dropdown-item danger"
|
||||
onClick={() => {
|
||||
logout();
|
||||
setUserDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<polyline points="16 17 21 12 16 7" />
|
||||
<line x1="21" y1="12" x2="9" y2="12" />
|
||||
</svg>
|
||||
{t("auth.signOut", "Sign Out")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+704
-620
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
// ConfirmModal Component - Simple confirmation dialog
|
||||
// =============================================================================
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface ConfirmModalProps {
|
||||
title: string;
|
||||
@@ -23,7 +23,7 @@ export function ConfirmModal({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
isLoading = false,
|
||||
confirmVariant = "primary"
|
||||
confirmVariant = "primary",
|
||||
}: ConfirmModalProps) {
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onCancel}>
|
||||
@@ -33,10 +33,7 @@ export function ConfirmModal({
|
||||
</button>
|
||||
<h2 style={{ marginBottom: "16px", paddingRight: "2rem" }}>{title}</h2>
|
||||
<div style={{ marginBottom: "24px" }}>{typeof message === "string" ? <p>{message}</p> : message}</div>
|
||||
<div
|
||||
className="modal-footer"
|
||||
style={{ padding: "1rem 0 0 0", borderTop: "none", justifyContent: "flex-end" }}
|
||||
>
|
||||
<div className="modal-footer" style={{ padding: "1rem 0 0 0", borderTop: "none", justifyContent: "flex-end" }}>
|
||||
<button type="button" className="ghost" onClick={onCancel} disabled={isLoading}>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ExportModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -14,10 +14,12 @@ export default function ExportModal({ isOpen, onClose, onExport, exporting }: Ex
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{maxWidth: "450px"}}>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
<h2 style={{marginBottom: "16px", paddingRight: "2rem"}}>{t('exportImport.exportOptions')}</h2>
|
||||
<div style={{display: 'flex', flexDirection: 'column', gap: '12px'}}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: "450px" }}>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
<h2 style={{ marginBottom: "16px", paddingRight: "2rem" }}>{t("exportImport.exportOptions")}</h2>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
|
||||
<button
|
||||
type="button"
|
||||
className="action-card"
|
||||
@@ -26,11 +28,11 @@ export default function ExportModal({ isOpen, onClose, onExport, exporting }: Ex
|
||||
onExport(true);
|
||||
}}
|
||||
disabled={exporting}
|
||||
style={{textAlign: 'left', cursor: 'pointer', border: '1px solid var(--border)', borderRadius: '8px'}}
|
||||
style={{ textAlign: "left", cursor: "pointer", border: "1px solid var(--border)", borderRadius: "8px" }}
|
||||
>
|
||||
<div className="action-card-content" style={{flex: 1}}>
|
||||
<span className="action-card-title">{t('exportImport.exportWithImages')}</span>
|
||||
<span className="action-card-desc">{t('exportImport.exportWithImagesDesc')}</span>
|
||||
<div className="action-card-content" style={{ flex: 1 }}>
|
||||
<span className="action-card-title">{t("exportImport.exportWithImages")}</span>
|
||||
<span className="action-card-desc">{t("exportImport.exportWithImagesDesc")}</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
@@ -41,21 +43,17 @@ export default function ExportModal({ isOpen, onClose, onExport, exporting }: Ex
|
||||
onExport(false);
|
||||
}}
|
||||
disabled={exporting}
|
||||
style={{textAlign: 'left', cursor: 'pointer', border: '1px solid var(--border)', borderRadius: '8px'}}
|
||||
style={{ textAlign: "left", cursor: "pointer", border: "1px solid var(--border)", borderRadius: "8px" }}
|
||||
>
|
||||
<div className="action-card-content" style={{flex: 1}}>
|
||||
<span className="action-card-title">{t('exportImport.exportDataOnly')}</span>
|
||||
<span className="action-card-desc">{t('exportImport.exportDataOnlyDesc')}</span>
|
||||
<div className="action-card-content" style={{ flex: 1 }}>
|
||||
<span className="action-card-title">{t("exportImport.exportDataOnly")}</span>
|
||||
<span className="action-card-desc">{t("exportImport.exportDataOnlyDesc")}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-footer" style={{padding: "1rem 0 0 0", borderTop: "none", justifyContent: "flex-end"}}>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost"
|
||||
onClick={onClose}
|
||||
>
|
||||
{t('exportImport.cancelButton')}
|
||||
<div className="modal-footer" style={{ padding: "1rem 0 0 0", borderTop: "none", justifyContent: "flex-end" }}>
|
||||
<button type="button" className="ghost" onClick={onClose}>
|
||||
{t("exportImport.cancelButton")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Lightbox Component - Full-screen image viewer
|
||||
// =============================================================================
|
||||
|
||||
import { MouseEvent } from "react";
|
||||
import type { MouseEvent } from "react";
|
||||
|
||||
export interface LightboxProps {
|
||||
src: string;
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
/**
|
||||
* MedDetailModal - Medication detail view with nested modals
|
||||
* Displays medication information, stock, schedules, and provides refill/edit functionality
|
||||
*
|
||||
*
|
||||
* Can work in two modes:
|
||||
* 1. Context mode: Uses useAppContext() for all state (when no props provided)
|
||||
* 2. Props mode: Accepts all required data as props (for gradual adoption)
|
||||
*/
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Medication, Coverage, RefillEntry, StockThresholds } from "../types";
|
||||
import { MedicationAvatar, Lightbox } from "../components";
|
||||
import { Lightbox, MedicationAvatar } from "../components";
|
||||
import type { Coverage, Medication, RefillEntry, StockThresholds } from "../types";
|
||||
import { getMedTotal, getPackageSize } from "../types";
|
||||
import { formatNumber, generateICS, getExpiryClass, getSystemLocale } from "../utils";
|
||||
import { getStockStatus } from "../utils/schedule";
|
||||
@@ -135,7 +135,8 @@ export function MedDetailModal({
|
||||
const packageSize = getPackageSize(selectedMed);
|
||||
const currentStock = medCoverage ? Math.round(medCoverage.medsLeft) : getMedTotal(selectedMed);
|
||||
const status = medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null;
|
||||
const textClass = status?.className === "danger" ? "danger-text" : status?.className === "warning" ? "warning-text" : "success-text";
|
||||
const textClass =
|
||||
status?.className === "danger" ? "danger-text" : status?.className === "warning" ? "warning-text" : "success-text";
|
||||
const stock = getBlisterStock(currentStock, selectedMed.pillsPerBlister, selectedMed.looseTablets, packageSize);
|
||||
|
||||
return (
|
||||
@@ -170,20 +171,30 @@ export function MedDetailModal({
|
||||
<div className="med-detail-section">
|
||||
<h3>{t("modal.stockInfo")}</h3>
|
||||
<div className="med-detail-grid">
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("table.fullBlisters")}</span>
|
||||
<span className={`med-detail-value ${textClass}`}>{formatFullBlisters(stock.fullBlisters, t)}</span>
|
||||
</div>
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("table.openBlister")}</span>
|
||||
<span className={`med-detail-value ${textClass}`}>
|
||||
{formatOpenBlisterAndLoose(stock.openBlisterPills, stock.loosePills, selectedMed.pillsPerBlister ?? 1, t)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="med-detail-item full-width">
|
||||
{selectedMed.packageType === "blister" && (
|
||||
<>
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("table.fullBlisters")}</span>
|
||||
<span className={`med-detail-value ${textClass}`}>{formatFullBlisters(stock.fullBlisters, t)}</span>
|
||||
</div>
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("table.openBlister")}</span>
|
||||
<span className={`med-detail-value ${textClass}`}>
|
||||
{formatOpenBlisterAndLoose(
|
||||
stock.openBlisterPills,
|
||||
stock.loosePills,
|
||||
selectedMed.pillsPerBlister ?? 1,
|
||||
t
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className={`med-detail-item ${selectedMed.packageType === "bottle" ? "full-width" : "full-width"}`}>
|
||||
<span className="med-detail-label">{t("modal.currentStock")}</span>
|
||||
<span className={`med-detail-value ${textClass}`}>
|
||||
{currentStock} / {packageSize}
|
||||
{currentStock} /{" "}
|
||||
{selectedMed.packageType === "bottle" ? (selectedMed.totalPills ?? packageSize) : packageSize}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -193,28 +204,41 @@ export function MedDetailModal({
|
||||
<div className="med-detail-section">
|
||||
<h3>{t("modal.packageDetails")}</h3>
|
||||
<div className="med-detail-grid">
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("modal.packs")}</span>
|
||||
<span className="med-detail-value">{selectedMed.packCount}</span>
|
||||
</div>
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("modal.blistersPerPack")}</span>
|
||||
<span className="med-detail-value">{selectedMed.blistersPerPack}</span>
|
||||
</div>
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("modal.pillsPerBlister")}</span>
|
||||
<span className="med-detail-value">{selectedMed.pillsPerBlister}</span>
|
||||
</div>
|
||||
{selectedMed.packageType === "blister" ? (
|
||||
<>
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("modal.packs")}</span>
|
||||
<span className="med-detail-value">{selectedMed.packCount}</span>
|
||||
</div>
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("modal.blistersPerPack")}</span>
|
||||
<span className="med-detail-value">{selectedMed.blistersPerPack}</span>
|
||||
</div>
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("modal.pillsPerBlister")}</span>
|
||||
<span className="med-detail-value">{selectedMed.pillsPerBlister}</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("form.totalCapacity")}</span>
|
||||
<span className="med-detail-value">{selectedMed.totalPills ?? "—"}</span>
|
||||
</div>
|
||||
)}
|
||||
{selectedMed.pillWeightMg && (
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("modal.pillWeight")}</span>
|
||||
<span className="med-detail-value">{selectedMed.pillWeightMg} mg</span>
|
||||
<span className="med-detail-value">
|
||||
{selectedMed.pillWeightMg} {selectedMed.doseUnit ?? "mg"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{selectedMed.expiryDate && (
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("modal.expiryDate")}</span>
|
||||
<span className={`med-detail-value ${getExpiryClass(selectedMed.expiryDate, settings.expiryWarningDays)}`}>
|
||||
<span
|
||||
className={`med-detail-value ${getExpiryClass(selectedMed.expiryDate, settings.expiryWarningDays)}`}
|
||||
>
|
||||
{new Date(selectedMed.expiryDate).toLocaleDateString(getSystemLocale(i18n.language), {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
@@ -245,10 +269,12 @@ export function MedDetailModal({
|
||||
<div key={idx} className="med-schedule-item">
|
||||
<span className="med-schedule-usage">
|
||||
{totalUsage} {totalUsage !== 1 ? t("common.pills") : t("common.pill")}
|
||||
{selectedMed.pillWeightMg && ` (${totalUsage * selectedMed.pillWeightMg} mg)`}
|
||||
{selectedMed.pillWeightMg &&
|
||||
` (${totalUsage * selectedMed.pillWeightMg} ${selectedMed.doseUnit ?? "mg"})`}
|
||||
</span>
|
||||
<span className="med-schedule-freq">
|
||||
{t("form.blisters.every")} {blister.every} {blister.every !== 1 ? t("common.days") : t("common.day")}
|
||||
{t("form.blisters.every")} {blister.every}{" "}
|
||||
{blister.every !== 1 ? t("common.days") : t("common.day")}
|
||||
</span>
|
||||
<span className="med-schedule-time">
|
||||
{t("modal.at")}{" "}
|
||||
@@ -274,7 +300,9 @@ export function MedDetailModal({
|
||||
<div className="med-detail-grid">
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("modal.daysLeft")}</span>
|
||||
<span className="med-detail-value">{medCoverage.daysLeft !== null ? formatNumber(medCoverage.daysLeft) : "—"}</span>
|
||||
<span className="med-detail-value">
|
||||
{medCoverage.daysLeft !== null ? formatNumber(medCoverage.daysLeft) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("modal.runsOut")}</span>
|
||||
@@ -295,7 +323,10 @@ export function MedDetailModal({
|
||||
{/* Refill History Section */}
|
||||
{refillHistory.length > 0 && (
|
||||
<div className="med-detail-section">
|
||||
<h3 className="section-header-clickable" onClick={() => onRefillHistoryExpandedChange(!refillHistoryExpanded)}>
|
||||
<h3
|
||||
className="section-header-clickable"
|
||||
onClick={() => onRefillHistoryExpandedChange(!refillHistoryExpanded)}
|
||||
>
|
||||
{t("refill.history")} ({refillHistory.length})
|
||||
<span className="expand-arrow">{refillHistoryExpanded ? "▼" : "▶"}</span>
|
||||
</h3>
|
||||
@@ -316,7 +347,9 @@ export function MedDetailModal({
|
||||
})}
|
||||
</span>
|
||||
<span className="refill-amount">
|
||||
+{entry.packsAdded * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + entry.loosePillsAdded}{" "}
|
||||
+
|
||||
{entry.packsAdded * selectedMed.blistersPerPack * selectedMed.pillsPerBlister +
|
||||
entry.loosePillsAdded}{" "}
|
||||
{t("common.pills")}
|
||||
</span>
|
||||
</div>
|
||||
@@ -338,7 +371,11 @@ export function MedDetailModal({
|
||||
{t("common.edit")}
|
||||
</button>
|
||||
{selectedMed.blisters.length > 0 && (
|
||||
<button className="secondary icon-only" onClick={() => generateICS(selectedMed)} title={t("modal.exportTooltip")}>
|
||||
<button
|
||||
className="secondary icon-only"
|
||||
onClick={() => generateICS(selectedMed)}
|
||||
title={t("modal.exportTooltip")}
|
||||
>
|
||||
📅
|
||||
</button>
|
||||
)}
|
||||
@@ -370,11 +407,21 @@ export function MedDetailModal({
|
||||
<div className="refill-form">
|
||||
<label>
|
||||
{t("refill.packs")}
|
||||
<input type="number" min="0" value={refillPacks} onChange={(e) => onRefillPacksChange(parseInt(e.target.value) || 0)} />
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={refillPacks}
|
||||
onChange={(e) => onRefillPacksChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("refill.loosePills")}
|
||||
<input type="number" min="0" value={refillLoose} onChange={(e) => onRefillLooseChange(parseInt(e.target.value) || 0)} />
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={refillLoose}
|
||||
onChange={(e) => onRefillLooseChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -392,7 +439,8 @@ export function MedDetailModal({
|
||||
</button>
|
||||
{(refillPacks > 0 || refillLoose > 0) && (
|
||||
<span className="refill-preview">
|
||||
+{refillPacks * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + refillLoose} {t("common.pills")}
|
||||
+{refillPacks * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + refillLoose}{" "}
|
||||
{t("common.pills")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -428,12 +476,13 @@ export function MedDetailModal({
|
||||
<>
|
||||
<div className="edit-stock-form">
|
||||
<label>
|
||||
{t("editStock.fullBlisters")} {t("editStock.pillsPerBlister", { count: selectedMed.pillsPerBlister })}
|
||||
{t("editStock.fullBlisters")}{" "}
|
||||
{t("editStock.pillsPerBlister", { count: selectedMed.pillsPerBlister })}
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={editStockFullBlisters}
|
||||
onChange={(e) => onEditStockFullBlistersChange(parseInt(e.target.value) || 0)}
|
||||
onChange={(e) => onEditStockFullBlistersChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
@@ -444,7 +493,7 @@ export function MedDetailModal({
|
||||
max={selectedMed.pillsPerBlister}
|
||||
value={editStockPartialBlisterPills}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value) || 0;
|
||||
const val = parseInt(e.target.value, 10) || 0;
|
||||
const min = editStockFullBlisters > 0 ? -(selectedMed.pillsPerBlister - 1) : 0;
|
||||
const max = selectedMed.pillsPerBlister;
|
||||
onEditStockPartialBlisterPillsChange(Math.max(min, Math.min(val, max)));
|
||||
@@ -466,7 +515,9 @@ export function MedDetailModal({
|
||||
{newTotal} {t("common.pills")}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`summary-row difference ${difference > 0 ? "positive" : difference < 0 ? "negative" : ""}`}>
|
||||
<div
|
||||
className={`summary-row difference ${difference > 0 ? "positive" : difference < 0 ? "negative" : ""}`}
|
||||
>
|
||||
<span>{t("editStock.difference")}:</span>
|
||||
<span>
|
||||
{difference > 0 ? "+" : ""}
|
||||
|
||||
@@ -9,9 +9,15 @@ export type MedicationAvatarProps = {
|
||||
};
|
||||
|
||||
export function MedicationAvatar({ name, imageUrl, size = "sm" }: MedicationAvatarProps) {
|
||||
const initials = name.split(" ").map(w => w[0]).join("").toUpperCase().slice(0, 2) || "?";
|
||||
const initials =
|
||||
name
|
||||
.split(" ")
|
||||
.map((w) => w[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2) || "?";
|
||||
const sizeClass = `med-avatar med-avatar-${size}`;
|
||||
|
||||
|
||||
if (imageUrl) {
|
||||
return <img src={`/api/images/${imageUrl}`} alt={name} className={sizeClass} />;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
* Handles new medication creation and editing existing medications
|
||||
*/
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Medication, FormState, FormBlister, FieldErrors } from "../types";
|
||||
import type { DoseUnit, FieldErrors, FormBlister, FormIntake, FormState, Medication } from "../types";
|
||||
import { DOSE_UNITS } from "../types";
|
||||
import { deriveTotal } from "../utils";
|
||||
|
||||
// Field limits for validation
|
||||
const FIELD_LIMITS = {
|
||||
@@ -30,10 +32,14 @@ export interface MobileEditModalProps {
|
||||
onAddTakenByPerson: (person: string) => void;
|
||||
onRemoveTakenByPerson: (person: string) => void;
|
||||
onTakenByKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
// Blister helpers
|
||||
// Blister helpers (legacy)
|
||||
onSetBlisterValue: (idx: number, field: keyof FormBlister, value: string) => void;
|
||||
onAddBlister: () => void;
|
||||
onRemoveBlister: (idx: number) => void;
|
||||
// Intake helpers (new - with per-intake takenBy)
|
||||
onSetIntakeValue: (idx: number, field: keyof FormIntake, value: string | boolean) => void;
|
||||
onAddIntake: (takenBy?: string) => void;
|
||||
onRemoveIntake: (idx: number) => void;
|
||||
// Value change handler for numeric fields
|
||||
onHandleValueChange: <K extends keyof FormState>(field: K, value: string) => void;
|
||||
// Refill state (for edit mode)
|
||||
@@ -53,12 +59,17 @@ export interface MobileEditModalProps {
|
||||
onSaveMedication: (e: React.FormEvent) => void;
|
||||
}
|
||||
|
||||
function deriveTotal(form: FormState) {
|
||||
/** Calculate total pills from form state */
|
||||
function deriveTotalFromForm(form: FormState) {
|
||||
if (form.packageType === "bottle") {
|
||||
// For bottle type, looseTablets is the current stock
|
||||
return Number(form.looseTablets) || 0;
|
||||
}
|
||||
const packCount = Number(form.packCount) || 0;
|
||||
const blistersPerPack = Number(form.blistersPerPack) || 0;
|
||||
const pillsPerBlister = Number(form.pillsPerBlister) || 1;
|
||||
const looseTablets = Number(form.looseTablets) || 0;
|
||||
return packCount * blistersPerPack * pillsPerBlister + looseTablets;
|
||||
return deriveTotal(packCount, blistersPerPack, pillsPerBlister, looseTablets);
|
||||
}
|
||||
|
||||
export function MobileEditModal({
|
||||
@@ -80,6 +91,9 @@ export function MobileEditModal({
|
||||
onSetBlisterValue,
|
||||
onAddBlister,
|
||||
onRemoveBlister,
|
||||
onSetIntakeValue,
|
||||
onAddIntake,
|
||||
onRemoveIntake,
|
||||
onHandleValueChange,
|
||||
refillPacks,
|
||||
onRefillPacksChange,
|
||||
@@ -91,7 +105,7 @@ export function MobileEditModal({
|
||||
onUploadMedImage,
|
||||
onDeleteMedImage,
|
||||
onClose,
|
||||
onResetForm,
|
||||
_onResetForm,
|
||||
onSaveMedication,
|
||||
}: MobileEditModalProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -103,19 +117,26 @@ export function MobileEditModal({
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content edit-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
className="modal-close"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onResetForm();
|
||||
}}
|
||||
>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
<div className="edit-modal-header">
|
||||
<h2>{editingId ? t("form.editEntry") : t("form.newEntry")}</h2>
|
||||
</div>
|
||||
<form className="form-grid mobile-edit-form" onSubmit={onSaveMedication}>
|
||||
<form
|
||||
className="form-grid mobile-edit-form"
|
||||
onSubmit={(e) => {
|
||||
// Check native HTML5 validation first
|
||||
const formElement = e.currentTarget;
|
||||
if (!formElement.checkValidity()) {
|
||||
// Let browser show native validation messages
|
||||
formElement.reportValidity();
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
onSaveMedication(e);
|
||||
}}
|
||||
>
|
||||
<label className={`full ${fieldErrors.name ? "has-error" : ""}`}>
|
||||
{t("form.commercialName")}
|
||||
<input
|
||||
@@ -155,7 +176,9 @@ export function MobileEditModal({
|
||||
onBlur={() => {
|
||||
if (takenByInput.trim()) onAddTakenByPerson(takenByInput);
|
||||
}}
|
||||
placeholder={form.takenBy.length === 0 ? t("form.placeholders.takenBy") : t("form.placeholders.addPerson")}
|
||||
placeholder={
|
||||
form.takenBy.length === 0 ? t("form.placeholders.takenBy") : t("form.placeholders.addPerson")
|
||||
}
|
||||
maxLength={FIELD_LIMITS.takenBy.max}
|
||||
list="takenby-suggestions-modal"
|
||||
/>
|
||||
@@ -169,41 +192,114 @@ export function MobileEditModal({
|
||||
</div>
|
||||
{fieldErrors.takenBy && <span className="field-error">{fieldErrors.takenBy}</span>}
|
||||
</label>
|
||||
<label>
|
||||
{t("form.packs")}
|
||||
<input type="number" min="0" value={form.packCount} onChange={(e) => onHandleValueChange("packCount", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
{t("form.blistersPerPack")}
|
||||
<input type="number" min="0" value={form.blistersPerPack} onChange={(e) => onHandleValueChange("blistersPerPack", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
{t("form.pillsPerBlister")}
|
||||
<input type="number" min="1" value={form.pillsPerBlister} onChange={(e) => onHandleValueChange("pillsPerBlister", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
{t("form.loosePills")}
|
||||
<input type="number" min="0" value={form.looseTablets} onChange={(e) => onHandleValueChange("looseTablets", e.target.value)} />
|
||||
<label className="full">
|
||||
{t("form.packageType")}
|
||||
<select
|
||||
className="package-type-select"
|
||||
value={form.packageType}
|
||||
onChange={(e) => onHandleValueChange("packageType", e.target.value)}
|
||||
>
|
||||
<option value="blister">{t("form.packageTypeBlister")}</option>
|
||||
<option value="bottle">{t("form.packageTypeBottle")}</option>
|
||||
</select>
|
||||
</label>
|
||||
{form.packageType === "blister" ? (
|
||||
<>
|
||||
<label>
|
||||
{t("form.packs")}
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={form.packCount}
|
||||
onChange={(e) => onHandleValueChange("packCount", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.blistersPerPack")}
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={form.blistersPerPack}
|
||||
onChange={(e) => onHandleValueChange("blistersPerPack", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.pillsPerBlister")}
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={form.pillsPerBlister}
|
||||
onChange={(e) => onHandleValueChange("pillsPerBlister", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.loosePills")}
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={form.looseTablets}
|
||||
onChange={(e) => onHandleValueChange("looseTablets", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label>
|
||||
{t("form.totalCapacity")}
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={form.totalPills}
|
||||
onChange={(e) => onHandleValueChange("totalPills", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.currentPills")}
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={form.looseTablets}
|
||||
onChange={(e) => onHandleValueChange("looseTablets", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<div className="full">
|
||||
<p className="sub">
|
||||
<strong>{t("form.total")}:</strong> {deriveTotal(form)} {t("common.pills")}
|
||||
<strong>{t("form.total")}:</strong> {deriveTotalFromForm(form)} {t("common.pills")}
|
||||
</p>
|
||||
</div>
|
||||
<label className="full">
|
||||
{t("form.pillWeight")}
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
value={form.pillWeightMg}
|
||||
onChange={(e) => onFormChange({ ...form, pillWeightMg: e.target.value })}
|
||||
placeholder={t("form.placeholders.weight")}
|
||||
/>
|
||||
{t("form.pillWeight")} ({form.doseUnit})
|
||||
<div className="dose-input-group">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
value={form.pillWeightMg}
|
||||
onChange={(e) => onFormChange({ ...form, pillWeightMg: e.target.value })}
|
||||
placeholder={t("form.placeholders.weight")}
|
||||
/>
|
||||
<select
|
||||
value={form.doseUnit}
|
||||
onChange={(e) => onFormChange({ ...form, doseUnit: e.target.value as DoseUnit })}
|
||||
className="dose-unit-select"
|
||||
>
|
||||
{DOSE_UNITS.map((unit) => (
|
||||
<option key={unit.value} value={unit.value}>
|
||||
{unit.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
<label className="full">
|
||||
{t("form.expiryDate")}
|
||||
<input type="date" value={form.expiryDate} onChange={(e) => onFormChange({ ...form, expiryDate: e.target.value })} />
|
||||
<input
|
||||
type="date"
|
||||
value={form.expiryDate}
|
||||
onChange={(e) => onFormChange({ ...form, expiryDate: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Refill section - only shown when editing (mobile) */}
|
||||
@@ -213,11 +309,21 @@ export function MobileEditModal({
|
||||
<div className="refill-form-inline">
|
||||
<label>
|
||||
{t("refill.packs")}
|
||||
<input type="number" min="0" value={refillPacks} onChange={(e) => onRefillPacksChange(parseInt(e.target.value) || 0)} />
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={refillPacks}
|
||||
onChange={(e) => onRefillPacksChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("refill.loosePills")}
|
||||
<input type="number" min="0" value={refillLoose} onChange={(e) => onRefillLooseChange(parseInt(e.target.value) || 0)} />
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={refillLoose}
|
||||
onChange={(e) => onRefillLooseChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
@@ -229,7 +335,8 @@ export function MobileEditModal({
|
||||
</button>
|
||||
{(refillPacks > 0 || refillLoose > 0) && (
|
||||
<span className="refill-preview">
|
||||
+{refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) + refillLoose} {t("common.pills")}
|
||||
+{refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) + refillLoose}{" "}
|
||||
{t("common.pills")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -248,7 +355,7 @@ export function MobileEditModal({
|
||||
onInput={(e) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
target.style.height = "auto";
|
||||
target.style.height = target.scrollHeight + "px";
|
||||
target.style.height = `${target.scrollHeight}px`;
|
||||
}}
|
||||
/>
|
||||
{form.notes.length > 0 && (
|
||||
@@ -263,7 +370,7 @@ export function MobileEditModal({
|
||||
<div className="full image-field">
|
||||
<span className="field-label">{t("form.medicationImage")}</span>
|
||||
<div className="image-preview">
|
||||
<img src={currentMed.imageUrl} alt={currentMed.name} />
|
||||
<img src={`/api/images/${currentMed.imageUrl}`} alt={currentMed.name} />
|
||||
<button type="button" className="danger" onClick={() => onDeleteMedImage(editingId)}>
|
||||
{t("form.removeImage")}
|
||||
</button>
|
||||
@@ -272,65 +379,93 @@ export function MobileEditModal({
|
||||
) : editingId ? (
|
||||
<label className="full">
|
||||
{t("form.medicationImage")}
|
||||
<input type="file" accept="image/*" onChange={(e) => e.target.files?.[0] && onUploadMedImage(editingId, e.target.files[0])} />
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => e.target.files?.[0] && onUploadMedImage(editingId, e.target.files[0])}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<fieldset className="full blister-section">
|
||||
<legend>
|
||||
{t("form.blisters.title")}
|
||||
<label className="toggle-switch small" title={t("form.blisters.remindTooltip")}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.intakeRemindersEnabled}
|
||||
onChange={(e) => onFormChange({ ...form, intakeRemindersEnabled: e.target.checked })}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
<span className="legend-hint">{t("form.blisters.remind")}</span>
|
||||
</legend>
|
||||
{form.blisters.map((b, idx) => (
|
||||
<legend>{t("form.blisters.title")}</legend>
|
||||
{form.intakes.map((intake, idx) => (
|
||||
<div key={idx} className="blister-row">
|
||||
<label className="compact">
|
||||
<span>{t("form.blisters.usage")}</span>
|
||||
<input type="number" min="0.5" step="0.5" value={b.usage} onChange={(e) => onSetBlisterValue(idx, "usage", e.target.value)} />
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
value={intake.usage}
|
||||
onChange={(e) => onSetIntakeValue(idx, "usage", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="compact">
|
||||
<span>{t("form.blisters.everyDays")}</span>
|
||||
<input type="number" min="1" value={b.every} onChange={(e) => onSetBlisterValue(idx, "every", e.target.value)} />
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={intake.every}
|
||||
onChange={(e) => onSetIntakeValue(idx, "every", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="compact full-row">
|
||||
<span>{t("form.blisters.startDate")}</span>
|
||||
<input type="date" value={b.startDate} onChange={(e) => onSetBlisterValue(idx, "startDate", e.target.value)} />
|
||||
<input
|
||||
type="date"
|
||||
value={intake.startDate}
|
||||
onChange={(e) => onSetIntakeValue(idx, "startDate", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="compact time-label">
|
||||
<span>{t("form.blisters.startTime")}</span>
|
||||
<input type="time" value={b.startTime} onChange={(e) => onSetBlisterValue(idx, "startTime", e.target.value)} />
|
||||
<input
|
||||
type="time"
|
||||
value={intake.startTime}
|
||||
onChange={(e) => onSetIntakeValue(idx, "startTime", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{form.blisters.length > 1 && (
|
||||
<button type="button" className="danger remove-blister-btn" onClick={() => onRemoveBlister(idx)}>
|
||||
<label className="compact full-row">
|
||||
<span>{t("form.blisters.takenByIntake")}</span>
|
||||
<select value={intake.takenBy} onChange={(e) => onSetIntakeValue(idx, "takenBy", e.target.value)}>
|
||||
<option value="">{t("form.blisters.takenByEveryone")}</option>
|
||||
{existingPeople.map((person) => (
|
||||
<option key={person} value={person}>
|
||||
{person}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="toggle-switch small" title={t("form.blisters.remindTooltip")}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={intake.intakeRemindersEnabled}
|
||||
onChange={(e) => onSetIntakeValue(idx, "intakeRemindersEnabled", e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
<span className="legend-hint">🔔</span>
|
||||
{form.intakes.length > 1 && (
|
||||
<button type="button" className="danger remove-blister-btn" onClick={() => onRemoveIntake(idx)}>
|
||||
{t("common.remove")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="ghost add-blister" onClick={onAddBlister}>
|
||||
<button type="button" className="ghost add-blister" onClick={() => onAddIntake()}>
|
||||
+ {t("form.blisters.addIntake")}
|
||||
</button>
|
||||
</fieldset>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="ghost"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onResetForm();
|
||||
}}
|
||||
>
|
||||
<button type="button" className="ghost" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button type="submit" disabled={saving || hasValidationErrors || (!formChanged && (formSaved || !!editingId))}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || hasValidationErrors || (!formChanged && (formSaved || !!editingId))}
|
||||
>
|
||||
{formSaved && !formChanged ? t("common.saved") : t("common.save")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState } from "react";
|
||||
|
||||
interface PasswordInputProps {
|
||||
id: string;
|
||||
value: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
required?: boolean;
|
||||
autoComplete?: string;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function PasswordInput({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
required,
|
||||
autoComplete,
|
||||
minLength,
|
||||
maxLength,
|
||||
placeholder,
|
||||
}: PasswordInputProps) {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="password-input-wrapper">
|
||||
<input
|
||||
id={id}
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
required={required}
|
||||
autoComplete={autoComplete}
|
||||
minLength={minLength}
|
||||
maxLength={maxLength}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="password-toggle-btn"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
tabIndex={-1}
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword ? (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
|
||||
<line x1="1" y1="1" x2="23" y2="23" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { UserProfile } from './Auth';
|
||||
import { UserProfile } from "./Auth";
|
||||
|
||||
interface ProfileModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -7,14 +6,14 @@ interface ProfileModalProps {
|
||||
}
|
||||
|
||||
export default function ProfileModal({ isOpen, onClose }: ProfileModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content profile-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
<UserProfile onClose={onClose} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
// TagInput Component - Reusable tag input with suggestions
|
||||
// =============================================================================
|
||||
|
||||
import { KeyboardEvent } from "react";
|
||||
import type { KeyboardEvent } from "react";
|
||||
|
||||
export interface TagInputProps {
|
||||
tags: string[];
|
||||
@@ -29,7 +29,7 @@ export function TagInput({
|
||||
addPlaceholder = "",
|
||||
maxLength,
|
||||
error,
|
||||
datalistId = "tag-suggestions"
|
||||
datalistId = "tag-suggestions",
|
||||
}: TagInputProps) {
|
||||
function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
|
||||
if ((e.key === "Enter" || e.key === ",") && inputValue.trim()) {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* Allows clicking through to medication details
|
||||
*/
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Medication, Coverage, StockThresholds } from "../types";
|
||||
import { MedicationAvatar } from "../components";
|
||||
import type { Coverage, Medication, StockThresholds } from "../types";
|
||||
import { getMedTotal, getPackageSize } from "../types";
|
||||
import { formatNumber } from "../utils";
|
||||
import { getStockStatus } from "../utils/schedule";
|
||||
|
||||
@@ -1,32 +1,24 @@
|
||||
// Components barrel export
|
||||
export { MedicationAvatar } from "./MedicationAvatar";
|
||||
export type { MedicationAvatarProps } from "./MedicationAvatar";
|
||||
|
||||
export { SharedSchedule } from "./SharedSchedule";
|
||||
|
||||
export { TagInput } from "./TagInput";
|
||||
export type { TagInputProps } from "./TagInput";
|
||||
|
||||
export { Lightbox } from "./Lightbox";
|
||||
export type { LightboxProps } from "./Lightbox";
|
||||
|
||||
export { ConfirmModal } from "./ConfirmModal";
|
||||
export type { ConfirmModalProps } from "./ConfirmModal";
|
||||
|
||||
export { MedDetailModal } from "./MedDetailModal";
|
||||
export type { MedDetailModalProps } from "./MedDetailModal";
|
||||
|
||||
export { UserFilterModal } from "./UserFilterModal";
|
||||
export type { UserFilterModalProps } from "./UserFilterModal";
|
||||
|
||||
export { ShareDialog } from "./ShareDialog";
|
||||
export type { ShareDialogProps } from "./ShareDialog";
|
||||
|
||||
export { MobileEditModal } from "./MobileEditModal";
|
||||
export type { MobileEditModalProps } from "./MobileEditModal";
|
||||
|
||||
export { default as ProfileModal } from "./ProfileModal";
|
||||
|
||||
export { default as AboutModal } from "./AboutModal";
|
||||
|
||||
export type { ConfirmModalProps } from "./ConfirmModal";
|
||||
export { ConfirmModal } from "./ConfirmModal";
|
||||
export { default as ExportModal } from "./ExportModal";
|
||||
export type { LightboxProps } from "./Lightbox";
|
||||
|
||||
export { Lightbox } from "./Lightbox";
|
||||
export type { MedDetailModalProps } from "./MedDetailModal";
|
||||
export { MedDetailModal } from "./MedDetailModal";
|
||||
export type { MedicationAvatarProps } from "./MedicationAvatar";
|
||||
export { MedicationAvatar } from "./MedicationAvatar";
|
||||
export type { MobileEditModalProps } from "./MobileEditModal";
|
||||
export { MobileEditModal } from "./MobileEditModal";
|
||||
export { PasswordInput } from "./PasswordInput";
|
||||
export { default as ProfileModal } from "./ProfileModal";
|
||||
export type { ShareDialogProps } from "./ShareDialog";
|
||||
export { ShareDialog } from "./ShareDialog";
|
||||
export { SharedSchedule } from "./SharedSchedule";
|
||||
export type { TagInputProps } from "./TagInput";
|
||||
export { TagInput } from "./TagInput";
|
||||
export type { UserFilterModalProps } from "./UserFilterModal";
|
||||
export { UserFilterModal } from "./UserFilterModal";
|
||||
|
||||
+505
-332
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,73 @@
|
||||
import { createContext, type ReactNode, useCallback, useContext, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ConfirmModal } from "../components/ConfirmModal";
|
||||
|
||||
interface UnsavedChangesContextValue {
|
||||
/** Whether there are unsaved changes anywhere in the app */
|
||||
hasUnsavedChanges: boolean;
|
||||
/** Register that a component has unsaved changes */
|
||||
setHasUnsavedChanges: (value: boolean) => void;
|
||||
/** Check and confirm navigation - returns a promise that resolves to true if navigation should proceed */
|
||||
confirmNavigation: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null);
|
||||
|
||||
export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
const [pendingResolve, setPendingResolve] = useState<((value: boolean) => void) | null>(null);
|
||||
|
||||
const confirmNavigation = useCallback((): Promise<boolean> => {
|
||||
if (!hasUnsavedChanges) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
setPendingResolve(() => resolve);
|
||||
setShowConfirmModal(true);
|
||||
});
|
||||
}, [hasUnsavedChanges]);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
setShowConfirmModal(false);
|
||||
if (pendingResolve) {
|
||||
pendingResolve(true);
|
||||
setPendingResolve(null);
|
||||
}
|
||||
}, [pendingResolve]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setShowConfirmModal(false);
|
||||
if (pendingResolve) {
|
||||
pendingResolve(false);
|
||||
setPendingResolve(null);
|
||||
}
|
||||
}, [pendingResolve]);
|
||||
|
||||
return (
|
||||
<UnsavedChangesContext.Provider value={{ hasUnsavedChanges, setHasUnsavedChanges, confirmNavigation }}>
|
||||
{children}
|
||||
{showConfirmModal && (
|
||||
<ConfirmModal
|
||||
title={t("common.unsavedChanges.title", "Unsaved Changes")}
|
||||
message={t("common.unsavedChanges.message")}
|
||||
confirmLabel={t("common.unsavedChanges.leave", "Leave")}
|
||||
cancelLabel={t("common.unsavedChanges.stay", "Stay")}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={handleCancel}
|
||||
confirmVariant="danger"
|
||||
/>
|
||||
)}
|
||||
</UnsavedChangesContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useUnsavedChanges() {
|
||||
const context = useContext(UnsavedChangesContext);
|
||||
if (!context) {
|
||||
throw new Error("useUnsavedChanges must be used within UnsavedChangesProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
// Context barrel export
|
||||
|
||||
export type { AppContextValue, DayMedEntry, DoseInfo, GroupedDay } from "./AppContext";
|
||||
export { AppProvider, useAppContext } from "./AppContext";
|
||||
export type { AppContextValue, DoseInfo, DayMedEntry, GroupedDay } from "./AppContext";
|
||||
export { UnsavedChangesProvider, useUnsavedChanges } from "./UnsavedChangesContext";
|
||||
|
||||
+16
-13
@@ -1,17 +1,20 @@
|
||||
// Hooks barrel export
|
||||
export { useDoses } from "./useDoses";
|
||||
export type { UseDosesReturn } from "./useDoses";
|
||||
export { useCollapsedDays } from "./useCollapsedDays";
|
||||
|
||||
export type { UseCollapsedDaysReturn } from "./useCollapsedDays";
|
||||
export { useTheme } from "./useTheme";
|
||||
export type { Theme, UseThemeReturn } from "./useTheme";
|
||||
export { useSettings } from "./useSettings";
|
||||
export type { Settings, UseSettingsReturn } from "./useSettings";
|
||||
export { useShare } from "./useShare";
|
||||
export type { UseShareReturn } from "./useShare";
|
||||
export { useMedications } from "./useMedications";
|
||||
export type { UseMedicationsReturn } from "./useMedications";
|
||||
export { useMedicationForm, defaultBlister, defaultForm } from "./useMedicationForm";
|
||||
export { useCollapsedDays } from "./useCollapsedDays";
|
||||
export type { UseDosesReturn } from "./useDoses";
|
||||
export { useDoses } from "./useDoses";
|
||||
export type { UseMedicationFormReturn } from "./useMedicationForm";
|
||||
export { defaultBlister, defaultForm, useMedicationForm } from "./useMedicationForm";
|
||||
export type { UseMedicationsReturn } from "./useMedications";
|
||||
export { useMedications } from "./useMedications";
|
||||
export type { UseRefillReturn } from "./useRefill";
|
||||
export { useRefill } from "./useRefill";
|
||||
export type { UseRefillReturn } from "./useRefill";
|
||||
export type { Settings, UseSettingsReturn } from "./useSettings";
|
||||
export { useSettings } from "./useSettings";
|
||||
export type { UseShareReturn } from "./useShare";
|
||||
export { useShare } from "./useShare";
|
||||
export type { Theme, UseThemeReturn } from "./useTheme";
|
||||
export { useTheme } from "./useTheme";
|
||||
export type { UseUnsavedChangesWarningReturn } from "./useUnsavedChangesWarning";
|
||||
export { useUnsavedChangesWarning } from "./useUnsavedChangesWarning";
|
||||
|
||||
@@ -62,6 +62,6 @@ export function useCollapsedDays(userId: number | undefined): UseCollapsedDaysRe
|
||||
return {
|
||||
manuallyCollapsedDays,
|
||||
manuallyExpandedDays,
|
||||
toggleDayCollapse
|
||||
toggleDayCollapse,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,14 +8,12 @@ export interface UseDosesReturn {
|
||||
takenDoses: Set<string>;
|
||||
setTakenDoses: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||
dismissedDoses: Set<string>;
|
||||
clearingMissed: boolean;
|
||||
showClearMissedConfirm: boolean;
|
||||
setShowClearMissedConfirm: (show: boolean) => void;
|
||||
getDoseId: (baseDoseId: string, person: string | null) => string;
|
||||
countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number };
|
||||
markDoseTaken: (doseId: string) => Promise<void>;
|
||||
undoDoseTaken: (doseId: string) => Promise<void>;
|
||||
dismissMissedDoses: (doseIds: string[]) => Promise<void>;
|
||||
loadTakenDoses: () => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -23,7 +21,6 @@ export function useDoses(): UseDosesReturn {
|
||||
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
|
||||
const [dismissedDoses, setDismissedDoses] = useState<Set<string>>(new Set());
|
||||
const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false);
|
||||
const [clearingMissed, setClearingMissed] = useState(false);
|
||||
|
||||
// Load taken doses from server
|
||||
const loadTakenDoses = useCallback(async () => {
|
||||
@@ -94,7 +91,7 @@ export function useDoses(): UseDosesReturn {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ doseId })
|
||||
body: JSON.stringify({ doseId }),
|
||||
});
|
||||
} catch {
|
||||
// Revert on error
|
||||
@@ -118,7 +115,7 @@ export function useDoses(): UseDosesReturn {
|
||||
try {
|
||||
await fetch(`/api/doses/taken/${encodeURIComponent(doseId)}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include"
|
||||
credentials: "include",
|
||||
});
|
||||
} catch {
|
||||
// Revert on error
|
||||
@@ -130,47 +127,16 @@ export function useDoses(): UseDosesReturn {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Dismiss missed doses without deducting from stock
|
||||
const dismissMissedDoses = useCallback(async (doseIds: string[]) => {
|
||||
if (doseIds.length === 0) return;
|
||||
|
||||
setClearingMissed(true);
|
||||
try {
|
||||
const res = await fetch("/api/doses/dismiss", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ doseIds })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
// Update local state - move these from neither set to dismissed set
|
||||
setDismissedDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of doseIds) next.add(id);
|
||||
return next;
|
||||
});
|
||||
setShowClearMissedConfirm(false);
|
||||
}
|
||||
} catch {
|
||||
// Error - dialog stays open
|
||||
} finally {
|
||||
setClearingMissed(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
takenDoses,
|
||||
setTakenDoses,
|
||||
dismissedDoses,
|
||||
clearingMissed,
|
||||
showClearMissedConfirm,
|
||||
setShowClearMissedConfirm,
|
||||
getDoseId,
|
||||
countTakenDoses,
|
||||
markDoseTaken,
|
||||
undoDoseTaken,
|
||||
dismissMissedDoses,
|
||||
loadTakenDoses
|
||||
loadTakenDoses,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useMemo, useEffect, useCallback } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Medication, FormState, FormBlister, FieldErrors } from "../types";
|
||||
import type { FieldErrors, FormBlister, FormIntake, FormState, Medication } from "../types";
|
||||
import { FIELD_LIMITS } from "../types";
|
||||
import { toDateValue, toTimeValue } from "../utils/formatters";
|
||||
|
||||
@@ -10,7 +10,22 @@ export const defaultBlister = (): FormBlister => {
|
||||
usage: "1",
|
||||
every: "1",
|
||||
startDate: toDateValue(now),
|
||||
startTime: toTimeValue(now)
|
||||
startTime: toTimeValue(now),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new intake with optional per-intake takenBy
|
||||
*/
|
||||
export const defaultIntake = (takenBy: string = ""): FormIntake => {
|
||||
const now = new Date();
|
||||
return {
|
||||
usage: "1",
|
||||
every: "1",
|
||||
startDate: toDateValue(now),
|
||||
startTime: toTimeValue(now),
|
||||
takenBy, // Per-intake user assignment (empty string = null/everyone)
|
||||
intakeRemindersEnabled: false,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -18,15 +33,19 @@ export const defaultForm = (): FormState => ({
|
||||
name: "",
|
||||
genericName: "",
|
||||
takenBy: [],
|
||||
packageType: "blister",
|
||||
packCount: "1",
|
||||
blistersPerPack: "1",
|
||||
pillsPerBlister: "1",
|
||||
totalPills: "",
|
||||
looseTablets: "0",
|
||||
pillWeightMg: "",
|
||||
doseUnit: "mg",
|
||||
expiryDate: "",
|
||||
notes: "",
|
||||
intakeRemindersEnabled: false,
|
||||
blisters: [defaultBlister()]
|
||||
blisters: [defaultBlister()],
|
||||
intakes: [defaultIntake()],
|
||||
});
|
||||
|
||||
export interface UseMedicationFormReturn {
|
||||
@@ -53,6 +72,10 @@ export interface UseMedicationFormReturn {
|
||||
setBlisterValue: (idx: number, field: keyof FormBlister, value: string) => void;
|
||||
addBlister: () => void;
|
||||
removeBlister: (idx: number) => void;
|
||||
// Intake management with per-intake takenBy
|
||||
setIntakeValue: (idx: number, field: keyof FormIntake, value: string | boolean) => void;
|
||||
addIntake: (takenBy?: string) => void;
|
||||
removeIntake: (idx: number) => void;
|
||||
startEdit: (med: Medication, openEditModal: () => void) => void;
|
||||
resetForm: () => void;
|
||||
handleValueChange: <K extends keyof FormState>(key: K, value: string) => void;
|
||||
@@ -74,23 +97,26 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
||||
const [takenByInput, setTakenByInput] = useState("");
|
||||
|
||||
// Validate form fields
|
||||
const validateField = useCallback((field: keyof FieldErrors, value: string | string[]): string | undefined => {
|
||||
const limits = FIELD_LIMITS[field];
|
||||
// Skip validation for takenBy array (individual items validated on add)
|
||||
if (field === 'takenBy') return undefined;
|
||||
const strValue = typeof value === 'string' ? value : '';
|
||||
if (field === 'name' && (!strValue || strValue.trim().length === 0)) {
|
||||
return t('common.validation.required');
|
||||
}
|
||||
if ('max' in limits && strValue.length > limits.max) {
|
||||
return t('common.validation.maxLength', { max: limits.max, current: strValue.length });
|
||||
}
|
||||
return undefined;
|
||||
}, [t]);
|
||||
const validateField = useCallback(
|
||||
(field: keyof FieldErrors, value: string | string[]): string | undefined => {
|
||||
const limits = FIELD_LIMITS[field];
|
||||
// Skip validation for takenBy array (individual items validated on add)
|
||||
if (field === "takenBy") return undefined;
|
||||
const strValue = typeof value === "string" ? value : "";
|
||||
if (field === "name" && (!strValue || strValue.trim().length === 0)) {
|
||||
return t("common.validation.required");
|
||||
}
|
||||
if ("max" in limits && strValue.length > limits.max) {
|
||||
return t("common.validation.maxLength", { max: limits.max, current: strValue.length });
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
// Check if form has any errors
|
||||
const hasValidationErrors = useMemo(() => {
|
||||
return Object.values(fieldErrors).some(error => error !== undefined);
|
||||
return Object.values(fieldErrors).some((error) => error !== undefined);
|
||||
}, [fieldErrors]);
|
||||
|
||||
// Check if form has been modified from original state
|
||||
@@ -108,9 +134,9 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
||||
// Validate all fields when form changes
|
||||
useEffect(() => {
|
||||
const errors: FieldErrors = {};
|
||||
(['name', 'genericName', 'notes'] as const).forEach(field => {
|
||||
const error = validateField(field, form[field]);
|
||||
if (error) errors[field] = error;
|
||||
(["name", "genericName", "notes"] as const).forEach((f) => {
|
||||
const error = validateField(f, form[f]);
|
||||
if (error) errors[f] = error;
|
||||
});
|
||||
setFieldErrors(errors);
|
||||
}, [form.name, form.genericName, form.notes, validateField]);
|
||||
@@ -131,28 +157,70 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
||||
setForm((prev) => ({ ...prev, blisters: prev.blisters.filter((_, i) => i !== idx) }));
|
||||
}, []);
|
||||
|
||||
// Intake management with per-intake takenBy
|
||||
const setIntakeValue = useCallback((idx: number, field: keyof FormIntake, value: string | boolean) => {
|
||||
setForm((prev) => {
|
||||
const next = [...prev.intakes];
|
||||
next[idx] = { ...next[idx], [field]: value };
|
||||
return { ...prev, intakes: next };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const addIntake = useCallback((takenBy: string = "") => {
|
||||
setForm((prev) => ({ ...prev, intakes: [...prev.intakes, defaultIntake(takenBy)] }));
|
||||
}, []);
|
||||
|
||||
const removeIntake = useCallback((idx: number) => {
|
||||
setForm((prev) => ({ ...prev, intakes: prev.intakes.filter((_, i) => i !== idx) }));
|
||||
}, []);
|
||||
|
||||
const startEdit = useCallback((med: Medication, openEditModal: () => void) => {
|
||||
setEditingId(med.id);
|
||||
setTakenByInput(""); // Clear tag input when starting edit
|
||||
setFormSaved(true); // Existing medication is already saved
|
||||
|
||||
// Parse intakes - prefer new format, fallback to legacy blisters
|
||||
const intakesFromApi =
|
||||
med.intakes && med.intakes.length > 0
|
||||
? med.intakes.map((i) => ({
|
||||
usage: String(i.usage),
|
||||
every: String(i.every),
|
||||
startDate: toDateValue(i.start),
|
||||
startTime: toTimeValue(i.start),
|
||||
takenBy: i.takenBy ?? "", // Convert null to empty string for form
|
||||
intakeRemindersEnabled: i.intakeRemindersEnabled,
|
||||
}))
|
||||
: med.blisters.map((s) => ({
|
||||
usage: String(s.usage),
|
||||
every: String(s.every),
|
||||
startDate: toDateValue(s.start),
|
||||
startTime: toTimeValue(s.start),
|
||||
takenBy: "", // Legacy blisters have no per-intake takenBy
|
||||
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
||||
}));
|
||||
|
||||
const editForm: FormState = {
|
||||
name: med.name,
|
||||
genericName: med.genericName ?? "",
|
||||
takenBy: med.takenBy || [], // Already an array from API
|
||||
packageType: med.packageType ?? "blister",
|
||||
packCount: String(med.packCount),
|
||||
blistersPerPack: String(med.blistersPerPack),
|
||||
pillsPerBlister: String(med.pillsPerBlister),
|
||||
totalPills: med.totalPills ? String(med.totalPills) : "",
|
||||
looseTablets: String(med.looseTablets),
|
||||
pillWeightMg: med.pillWeightMg ? String(med.pillWeightMg) : "",
|
||||
doseUnit: med.doseUnit ?? "mg",
|
||||
expiryDate: med.expiryDate ? med.expiryDate.slice(0, 10) : "",
|
||||
notes: med.notes ?? "",
|
||||
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
||||
blisters: med.blisters.map((s) => ({
|
||||
usage: String(s.usage),
|
||||
every: String(s.every),
|
||||
blisters: med.blisters.map((s) => ({
|
||||
usage: String(s.usage),
|
||||
every: String(s.every),
|
||||
startDate: toDateValue(s.start),
|
||||
startTime: toTimeValue(s.start)
|
||||
startTime: toTimeValue(s.start),
|
||||
})),
|
||||
intakes: intakesFromApi,
|
||||
};
|
||||
setForm(editForm);
|
||||
setOriginalForm(editForm);
|
||||
@@ -179,27 +247,33 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
||||
}, []);
|
||||
|
||||
// Tag input helpers for "Taken By" field
|
||||
const addTakenByPerson = useCallback((name: string) => {
|
||||
const trimmed = name.trim();
|
||||
if (trimmed && trimmed.length <= FIELD_LIMITS.takenBy.max && !form.takenBy.includes(trimmed)) {
|
||||
setForm(prev => ({ ...prev, takenBy: [...prev.takenBy, trimmed] }));
|
||||
}
|
||||
setTakenByInput("");
|
||||
}, [form.takenBy]);
|
||||
const addTakenByPerson = useCallback(
|
||||
(name: string) => {
|
||||
const trimmed = name.trim();
|
||||
if (trimmed && trimmed.length <= FIELD_LIMITS.takenBy.max && !form.takenBy.includes(trimmed)) {
|
||||
setForm((prev) => ({ ...prev, takenBy: [...prev.takenBy, trimmed] }));
|
||||
}
|
||||
setTakenByInput("");
|
||||
},
|
||||
[form.takenBy]
|
||||
);
|
||||
|
||||
const removeTakenByPerson = useCallback((name: string) => {
|
||||
setForm(prev => ({ ...prev, takenBy: prev.takenBy.filter(p => p !== name) }));
|
||||
setForm((prev) => ({ ...prev, takenBy: prev.takenBy.filter((p) => p !== name) }));
|
||||
}, []);
|
||||
|
||||
const handleTakenByKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault();
|
||||
addTakenByPerson(takenByInput);
|
||||
} else if (e.key === 'Backspace' && !takenByInput && form.takenBy.length > 0) {
|
||||
// Remove last tag on backspace when input is empty
|
||||
removeTakenByPerson(form.takenBy[form.takenBy.length - 1]);
|
||||
}
|
||||
}, [takenByInput, form.takenBy, addTakenByPerson, removeTakenByPerson]);
|
||||
const handleTakenByKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" || e.key === ",") {
|
||||
e.preventDefault();
|
||||
addTakenByPerson(takenByInput);
|
||||
} else if (e.key === "Backspace" && !takenByInput && form.takenBy.length > 0) {
|
||||
// Remove last tag on backspace when input is empty
|
||||
removeTakenByPerson(form.takenBy[form.takenBy.length - 1]);
|
||||
}
|
||||
},
|
||||
[takenByInput, form.takenBy, addTakenByPerson, removeTakenByPerson]
|
||||
);
|
||||
|
||||
return {
|
||||
form,
|
||||
@@ -225,6 +299,9 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
||||
setBlisterValue,
|
||||
addBlister,
|
||||
removeBlister,
|
||||
setIntakeValue,
|
||||
addIntake,
|
||||
removeIntake,
|
||||
startEdit,
|
||||
resetForm,
|
||||
handleValueChange,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import type { Medication } from "../types";
|
||||
|
||||
export interface UseMedicationsReturn {
|
||||
@@ -22,42 +22,52 @@ export function useMedications(): UseMedicationsReturn {
|
||||
|
||||
const loadMeds = useCallback(() => {
|
||||
setLoading(true);
|
||||
fetch("/api/medications")
|
||||
fetch("/api/medications", { credentials: "include" })
|
||||
.then((res) => res.json())
|
||||
.then((data) => setMeds(Array.isArray(data) ? data : []))
|
||||
.catch(() => setMeds([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const deleteMed = useCallback(async (id: number, editingId: number | null, resetForm: () => void) => {
|
||||
await fetch(`/api/medications/${id}`, { method: "DELETE" }).catch(() => null);
|
||||
if (editingId === id) resetForm();
|
||||
loadMeds();
|
||||
}, [loadMeds]);
|
||||
const deleteMed = useCallback(
|
||||
async (id: number, editingId: number | null, resetForm: () => void) => {
|
||||
await fetch(`/api/medications/${id}`, { method: "DELETE", credentials: "include" }).catch(() => null);
|
||||
if (editingId === id) resetForm();
|
||||
loadMeds();
|
||||
},
|
||||
[loadMeds]
|
||||
);
|
||||
|
||||
const uploadMedImage = useCallback(async (medId: number, file: File) => {
|
||||
setUploadingImage(true);
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/medications/${medId}/image`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
if (res.ok) {
|
||||
loadMeds();
|
||||
const uploadMedImage = useCallback(
|
||||
async (medId: number, file: File) => {
|
||||
setUploadingImage(true);
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/medications/${medId}/image`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
credentials: "include",
|
||||
});
|
||||
if (res.ok) {
|
||||
loadMeds();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setUploadingImage(false);
|
||||
}, [loadMeds]);
|
||||
setUploadingImage(false);
|
||||
},
|
||||
[loadMeds]
|
||||
);
|
||||
|
||||
const deleteMedImage = useCallback(async (medId: number) => {
|
||||
await fetch(`/api/medications/${medId}/image`, { method: "DELETE" }).catch(() => null);
|
||||
loadMeds();
|
||||
}, [loadMeds]);
|
||||
const deleteMedImage = useCallback(
|
||||
async (medId: number) => {
|
||||
await fetch(`/api/medications/${medId}/image`, { method: "DELETE", credentials: "include" }).catch(() => null);
|
||||
loadMeds();
|
||||
},
|
||||
[loadMeds]
|
||||
);
|
||||
|
||||
return {
|
||||
meds,
|
||||
|
||||
+110
-111
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import type { Medication, RefillEntry, Coverage, FormState } from "../types";
|
||||
import { useCallback, useState } from "react";
|
||||
import type { Coverage, FormState, Medication, RefillEntry } from "../types";
|
||||
import { getMedTotal } from "../types";
|
||||
|
||||
export interface UseRefillReturn {
|
||||
@@ -14,7 +14,7 @@ export interface UseRefillReturn {
|
||||
refillHistory: RefillEntry[];
|
||||
refillHistoryExpanded: boolean;
|
||||
setRefillHistoryExpanded: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
|
||||
|
||||
// Edit stock (correction) state
|
||||
showEditStockModal: boolean;
|
||||
setShowEditStockModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
@@ -23,7 +23,7 @@ export interface UseRefillReturn {
|
||||
editStockPartialBlisterPills: number;
|
||||
setEditStockPartialBlisterPills: React.Dispatch<React.SetStateAction<number>>;
|
||||
editStockSaving: boolean;
|
||||
|
||||
|
||||
// Actions
|
||||
loadRefillHistory: (medId: number) => Promise<void>;
|
||||
submitRefill: (
|
||||
@@ -32,11 +32,7 @@ export interface UseRefillReturn {
|
||||
setForm: React.Dispatch<React.SetStateAction<FormState>>,
|
||||
loadMeds: () => void
|
||||
) => Promise<void>;
|
||||
submitStockCorrection: (
|
||||
medId: number,
|
||||
selectedMed: Medication,
|
||||
loadMeds: () => void
|
||||
) => Promise<void>;
|
||||
submitStockCorrection: (medId: number, selectedMed: Medication, loadMeds: () => void) => Promise<void>;
|
||||
openRefillModal: () => void;
|
||||
closeRefillModal: () => void;
|
||||
openEditStockModal: (selectedMed: Medication, coverage: { all: Coverage[] }) => void;
|
||||
@@ -51,7 +47,7 @@ export function useRefill(): UseRefillReturn {
|
||||
const [refillSaving, setRefillSaving] = useState(false);
|
||||
const [refillHistory, setRefillHistory] = useState<RefillEntry[]>([]);
|
||||
const [refillHistoryExpanded, setRefillHistoryExpanded] = useState(false);
|
||||
|
||||
|
||||
// Edit stock (correction) state
|
||||
const [showEditStockModal, setShowEditStockModal] = useState(false);
|
||||
const [editStockFullBlisters, setEditStockFullBlisters] = useState(0);
|
||||
@@ -64,7 +60,7 @@ export function useRefill(): UseRefillReturn {
|
||||
const res = await fetch(`/api/medications/${medId}/refills`, { credentials: "include" });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setRefillHistory(Array.isArray(data) ? data : (data.refills || []));
|
||||
setRefillHistory(Array.isArray(data) ? data : data.refills || []);
|
||||
} else {
|
||||
setRefillHistory([]);
|
||||
}
|
||||
@@ -74,111 +70,114 @@ export function useRefill(): UseRefillReturn {
|
||||
}, []);
|
||||
|
||||
// Submit a refill
|
||||
const submitRefill = useCallback(async (
|
||||
medId: number,
|
||||
editingId: number | null,
|
||||
setForm: React.Dispatch<React.SetStateAction<FormState>>,
|
||||
loadMeds: () => void
|
||||
) => {
|
||||
if (refillPacks < 1 && refillLoose < 1) return;
|
||||
setRefillSaving(true);
|
||||
try {
|
||||
const res = await fetch(`/api/medications/${medId}/refill`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ packsAdded: refillPacks, loosePillsAdded: refillLoose }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
// Update form values if we're in edit mode
|
||||
if (editingId === medId && data.newStock) {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
packCount: String(data.newStock.packCount),
|
||||
looseTablets: String(data.newStock.looseTablets),
|
||||
}));
|
||||
const submitRefill = useCallback(
|
||||
async (
|
||||
medId: number,
|
||||
editingId: number | null,
|
||||
setForm: React.Dispatch<React.SetStateAction<FormState>>,
|
||||
loadMeds: () => void
|
||||
) => {
|
||||
if (refillPacks < 1 && refillLoose < 1) return;
|
||||
setRefillSaving(true);
|
||||
try {
|
||||
const res = await fetch(`/api/medications/${medId}/refill`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ packsAdded: refillPacks, loosePillsAdded: refillLoose }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
// Update form values if we're in edit mode
|
||||
if (editingId === medId && data.newStock) {
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
packCount: String(data.newStock.packCount),
|
||||
looseTablets: String(data.newStock.looseTablets),
|
||||
}));
|
||||
}
|
||||
// Reset refill form
|
||||
setRefillPacks(1);
|
||||
setRefillLoose(0);
|
||||
// Close refill modal via history back for proper back-button support
|
||||
if (showRefillModal) {
|
||||
window.history.back();
|
||||
}
|
||||
// Reload medications to get updated stock
|
||||
loadMeds();
|
||||
// Reload refill history
|
||||
await loadRefillHistory(medId);
|
||||
}
|
||||
// Reset refill form
|
||||
setRefillPacks(1);
|
||||
setRefillLoose(0);
|
||||
// Close refill modal via history back for proper back-button support
|
||||
if (showRefillModal) {
|
||||
window.history.back();
|
||||
}
|
||||
// Reload medications to get updated stock
|
||||
loadMeds();
|
||||
// Reload refill history
|
||||
await loadRefillHistory(medId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setRefillSaving(false);
|
||||
}, [refillPacks, refillLoose, showRefillModal, loadRefillHistory]);
|
||||
setRefillSaving(false);
|
||||
},
|
||||
[refillPacks, refillLoose, showRefillModal, loadRefillHistory]
|
||||
);
|
||||
|
||||
// Submit a stock correction - user says how many pills they have RIGHT NOW
|
||||
const submitStockCorrection = useCallback(async (
|
||||
medId: number,
|
||||
selectedMed: Medication,
|
||||
loadMeds: () => void
|
||||
) => {
|
||||
if (!selectedMed) return;
|
||||
setEditStockSaving(true);
|
||||
try {
|
||||
// Auto-convert: handle full blister and negative partial blister
|
||||
let finalFullBlisters = editStockFullBlisters;
|
||||
let finalPartialPills = editStockPartialBlisterPills;
|
||||
|
||||
// Handle full blister: e.g. 9 pills in a 9-pill blister = +1 full blister, 0 partial
|
||||
if (finalPartialPills >= selectedMed.pillsPerBlister) {
|
||||
finalFullBlisters += 1;
|
||||
finalPartialPills = 0;
|
||||
}
|
||||
|
||||
// Handle negative partial: e.g. -3 with 136 full = 135 full, 6 partial (for 9-pill blister)
|
||||
if (finalPartialPills < 0 && finalFullBlisters > 0) {
|
||||
finalFullBlisters -= 1;
|
||||
finalPartialPills = selectedMed.pillsPerBlister + finalPartialPills;
|
||||
}
|
||||
|
||||
// Ensure we don't go negative
|
||||
if (finalPartialPills < 0) finalPartialPills = 0;
|
||||
if (finalFullBlisters < 0) finalFullBlisters = 0;
|
||||
|
||||
// What the user says they have RIGHT NOW = the new DB total
|
||||
const desiredTotal = finalFullBlisters * selectedMed.pillsPerBlister + finalPartialPills;
|
||||
|
||||
// The "base" from DB structure (without any stockAdjustment)
|
||||
const baseTotal = selectedMed.packCount * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + selectedMed.looseTablets;
|
||||
|
||||
// stockAdjustment = what we need to make getMedTotal() return desiredTotal
|
||||
const newStockAdjustment = desiredTotal - baseTotal;
|
||||
|
||||
// Use the PATCH endpoint - it sets stockAdjustment AND lastStockCorrectionAt
|
||||
const res = await fetch(`/api/medications/${medId}/stock-adjustment`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ stockAdjustment: newStockAdjustment }),
|
||||
});
|
||||
if (res.ok) {
|
||||
// Close edit stock modal via history back
|
||||
if (showEditStockModal) {
|
||||
window.history.back();
|
||||
const submitStockCorrection = useCallback(
|
||||
async (medId: number, selectedMed: Medication, loadMeds: () => void) => {
|
||||
if (!selectedMed) return;
|
||||
setEditStockSaving(true);
|
||||
try {
|
||||
// Auto-convert: handle full blister and negative partial blister
|
||||
let finalFullBlisters = editStockFullBlisters;
|
||||
let finalPartialPills = editStockPartialBlisterPills;
|
||||
|
||||
// Handle full blister: e.g. 9 pills in a 9-pill blister = +1 full blister, 0 partial
|
||||
if (finalPartialPills >= selectedMed.pillsPerBlister) {
|
||||
finalFullBlisters += 1;
|
||||
finalPartialPills = 0;
|
||||
}
|
||||
// Reload medications to get updated stock
|
||||
loadMeds();
|
||||
|
||||
// Handle negative partial: e.g. -3 with 136 full = 135 full, 6 partial (for 9-pill blister)
|
||||
if (finalPartialPills < 0 && finalFullBlisters > 0) {
|
||||
finalFullBlisters -= 1;
|
||||
finalPartialPills = selectedMed.pillsPerBlister + finalPartialPills;
|
||||
}
|
||||
|
||||
// Ensure we don't go negative
|
||||
if (finalPartialPills < 0) finalPartialPills = 0;
|
||||
if (finalFullBlisters < 0) finalFullBlisters = 0;
|
||||
|
||||
// What the user says they have RIGHT NOW = the new DB total
|
||||
const desiredTotal = finalFullBlisters * selectedMed.pillsPerBlister + finalPartialPills;
|
||||
|
||||
// The "base" from DB structure (without any stockAdjustment)
|
||||
const baseTotal =
|
||||
selectedMed.packCount * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + selectedMed.looseTablets;
|
||||
|
||||
// stockAdjustment = what we need to make getMedTotal() return desiredTotal
|
||||
const newStockAdjustment = desiredTotal - baseTotal;
|
||||
|
||||
// Use the PATCH endpoint - it sets stockAdjustment AND lastStockCorrectionAt
|
||||
const res = await fetch(`/api/medications/${medId}/stock-adjustment`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ stockAdjustment: newStockAdjustment }),
|
||||
});
|
||||
if (res.ok) {
|
||||
// Close edit stock modal via history back
|
||||
if (showEditStockModal) {
|
||||
window.history.back();
|
||||
}
|
||||
// Reload medications to get updated stock
|
||||
loadMeds();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setEditStockSaving(false);
|
||||
}, [editStockFullBlisters, editStockPartialBlisterPills, showEditStockModal]);
|
||||
setEditStockSaving(false);
|
||||
},
|
||||
[editStockFullBlisters, editStockPartialBlisterPills, showEditStockModal]
|
||||
);
|
||||
|
||||
const openRefillModal = useCallback(() => {
|
||||
setShowRefillModal(true);
|
||||
window.history.pushState({ modal: 'refill' }, '');
|
||||
window.history.pushState({ modal: "refill" }, "");
|
||||
}, []);
|
||||
|
||||
const closeRefillModal = useCallback(() => {
|
||||
@@ -190,19 +189,19 @@ export function useRefill(): UseRefillReturn {
|
||||
const openEditStockModal = useCallback((selectedMed: Medication, coverage: { all: Coverage[] }) => {
|
||||
if (!selectedMed) return;
|
||||
// Get current stock from coverage (after consumption)
|
||||
const medCoverage = coverage.all.find(c => c.name === selectedMed.name);
|
||||
const medCoverage = coverage.all.find((c) => c.name === selectedMed.name);
|
||||
const dbTotal = getMedTotal(selectedMed);
|
||||
const currentStock = medCoverage ? Math.round(medCoverage.medsLeft) : dbTotal;
|
||||
|
||||
|
||||
// Simply divide into full blisters and partial
|
||||
const fullBlisters = Math.floor(currentStock / selectedMed.pillsPerBlister);
|
||||
const partialPills = currentStock % selectedMed.pillsPerBlister;
|
||||
|
||||
|
||||
// Pre-fill with current values
|
||||
setEditStockFullBlisters(fullBlisters);
|
||||
setEditStockPartialBlisterPills(partialPills);
|
||||
setShowEditStockModal(true);
|
||||
window.history.pushState({ modal: 'editStock' }, '');
|
||||
window.history.pushState({ modal: "editStock" }, "");
|
||||
}, []);
|
||||
|
||||
const closeEditStockModal = useCallback(() => {
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface Settings {
|
||||
nextScheduledCheck: string | null;
|
||||
lastNotificationType: "stock" | "intake" | null;
|
||||
lastNotificationChannel: "email" | "push" | "both" | null;
|
||||
lastReminderMedName: string | null;
|
||||
lastReminderTakenBy: string | null;
|
||||
shoutrrrEnabled: boolean;
|
||||
shoutrrrUrl: string;
|
||||
emailStockReminders: boolean;
|
||||
@@ -61,6 +63,8 @@ const defaultSettings: Settings = {
|
||||
nextScheduledCheck: null,
|
||||
lastNotificationType: null,
|
||||
lastNotificationChannel: null,
|
||||
lastReminderMedName: null,
|
||||
lastReminderTakenBy: null,
|
||||
shoutrrrEnabled: false,
|
||||
shoutrrrUrl: "",
|
||||
emailStockReminders: true,
|
||||
@@ -68,7 +72,7 @@ const defaultSettings: Settings = {
|
||||
shoutrrrStockReminders: true,
|
||||
shoutrrrIntakeReminders: true,
|
||||
stockCalculationMode: "automatic",
|
||||
expiryWarningDays: 30
|
||||
expiryWarningDays: 30,
|
||||
};
|
||||
|
||||
export interface UseSettingsReturn {
|
||||
@@ -123,6 +127,37 @@ export function useSettings(): UseSettingsReturn {
|
||||
loadSettings();
|
||||
}, [loadSettings]);
|
||||
|
||||
// Auto-refresh reminder status (last sent timestamp) every 30 seconds
|
||||
useEffect(() => {
|
||||
const refreshReminderStatus = () => {
|
||||
fetch("/api/settings", { credentials: "include" })
|
||||
.then((res) => (res.ok ? res.json() : Promise.reject()))
|
||||
.then((data) => {
|
||||
// Only update the reminder-related fields without triggering unsaved changes
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
lastAutoEmailSent: data.lastAutoEmailSent ?? prev.lastAutoEmailSent,
|
||||
lastNotificationType: data.lastNotificationType ?? prev.lastNotificationType,
|
||||
lastNotificationChannel: data.lastNotificationChannel ?? prev.lastNotificationChannel,
|
||||
lastReminderMedName: data.lastReminderMedName ?? prev.lastReminderMedName,
|
||||
lastReminderTakenBy: data.lastReminderTakenBy ?? prev.lastReminderTakenBy,
|
||||
}));
|
||||
setSavedSettings((prev) => ({
|
||||
...prev,
|
||||
lastAutoEmailSent: data.lastAutoEmailSent ?? prev.lastAutoEmailSent,
|
||||
lastNotificationType: data.lastNotificationType ?? prev.lastNotificationType,
|
||||
lastNotificationChannel: data.lastNotificationChannel ?? prev.lastNotificationChannel,
|
||||
lastReminderMedName: data.lastReminderMedName ?? prev.lastReminderMedName,
|
||||
lastReminderTakenBy: data.lastReminderTakenBy ?? prev.lastReminderTakenBy,
|
||||
}));
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
const interval = setInterval(refreshReminderStatus, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const saveSettings = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -134,7 +169,7 @@ export function useSettings(): UseSettingsReturn {
|
||||
|
||||
// Validate email if email notifications are enabled
|
||||
if (effectiveEmailEnabled && settings.notificationEmail) {
|
||||
const emailRegex = /^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$/i;
|
||||
const emailRegex = /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i;
|
||||
if (!emailRegex.test(settings.notificationEmail)) {
|
||||
setTestEmailResult({ success: false, message: "Invalid email address" });
|
||||
return;
|
||||
@@ -169,19 +204,20 @@ export function useSettings(): UseSettingsReturn {
|
||||
smtpUser: settings.smtpUser,
|
||||
smtpPass: settings.smtpPass || undefined,
|
||||
smtpFrom: settings.smtpFrom,
|
||||
smtpSecure: settings.smtpSecure
|
||||
smtpSecure: settings.smtpSecure,
|
||||
};
|
||||
|
||||
await fetch("/api/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
credentials: "include",
|
||||
body: JSON.stringify(payload),
|
||||
}).catch(() => null);
|
||||
|
||||
const updatedSettings = {
|
||||
...settings,
|
||||
emailEnabled: effectiveEmailEnabled,
|
||||
shoutrrrEnabled: effectiveShoutrrrEnabled
|
||||
shoutrrrEnabled: effectiveShoutrrrEnabled,
|
||||
};
|
||||
setSettings(updatedSettings);
|
||||
setSettingsSaving(false);
|
||||
@@ -198,10 +234,14 @@ export function useSettings(): UseSettingsReturn {
|
||||
const res = await fetch("/api/settings/test-email", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: settings.notificationEmail })
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ email: settings.notificationEmail }),
|
||||
});
|
||||
const data = await res.json();
|
||||
setTestEmailResult({ success: res.ok, message: data.message || (res.ok ? "Email sent!" : "Failed to send email") });
|
||||
setTestEmailResult({
|
||||
success: res.ok,
|
||||
message: data.message || (res.ok ? "Email sent!" : "Failed to send email"),
|
||||
});
|
||||
} catch {
|
||||
setTestEmailResult({ success: false, message: "Failed to send test email" });
|
||||
} finally {
|
||||
@@ -216,12 +256,13 @@ export function useSettings(): UseSettingsReturn {
|
||||
const res = await fetch("/api/settings/test-shoutrrr", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: settings.shoutrrrUrl })
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ url: settings.shoutrrrUrl }),
|
||||
});
|
||||
const data = await res.json();
|
||||
setTestShoutrrrResult({
|
||||
success: res.ok,
|
||||
message: data.message || (res.ok ? "Notification sent!" : "Failed to send notification")
|
||||
message: data.message || (res.ok ? "Notification sent!" : "Failed to send notification"),
|
||||
});
|
||||
} catch {
|
||||
setTestShoutrrrResult({ success: false, message: "Failed to send test notification" });
|
||||
@@ -250,6 +291,6 @@ export function useSettings(): UseSettingsReturn {
|
||||
saveSettings,
|
||||
testEmail,
|
||||
testShoutrrr,
|
||||
hasUnsavedChanges
|
||||
hasUnsavedChanges,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,10 +59,11 @@ export function useShare(): UseShareReturn {
|
||||
const res = await fetch("/api/share", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
takenBy: shareSelectedPerson,
|
||||
scheduleDays: shareSelectedDays
|
||||
})
|
||||
scheduleDays: shareSelectedDays,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
@@ -117,6 +118,6 @@ export function useShare(): UseShareReturn {
|
||||
generateShareLink,
|
||||
copyShareLink,
|
||||
closeShareDialog,
|
||||
resetShareDialogState
|
||||
resetShareDialogState,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface UseUnsavedChangesWarningReturn {
|
||||
/** Whether there are unsaved changes */
|
||||
hasUnsavedChanges: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that warns users when trying to close the browser/tab with unsaved changes.
|
||||
* For in-app navigation, use manual confirmation checks in your components.
|
||||
*/
|
||||
export function useUnsavedChangesWarning(hasUnsavedChanges: boolean): UseUnsavedChangesWarningReturn {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Handle browser refresh/close
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (hasUnsavedChanges) {
|
||||
e.preventDefault();
|
||||
// Modern browsers ignore custom messages, but we still need to set returnValue
|
||||
e.returnValue = t("common.unsavedChanges.message");
|
||||
return e.returnValue;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
}, [hasUnsavedChanges, t]);
|
||||
|
||||
return { hasUnsavedChanges };
|
||||
}
|
||||
+65
-20
@@ -17,11 +17,16 @@
|
||||
},
|
||||
"dashboard": {
|
||||
"reorder": {
|
||||
"title": "Nachbestell-Erinnerung",
|
||||
"title": "Nachfüll-Erinnerung",
|
||||
"badge": "Bestandsüberwachung",
|
||||
"noMeds": "Noch keine Medikamente konfiguriert.",
|
||||
"allGood": "Alles in Ordnung, genug Vorrat.", "lowWarning": "Genug Vorrat, aber {{count}} Medikament wird knapp.",
|
||||
"lowWarning_other": "Genug Vorrat, aber {{count}} Medikamente werden knapp.", "sendReminder": "🔔 Erinnerung jetzt senden"
|
||||
"allGood": "Alles in Ordnung, genug Vorrat.",
|
||||
"lowWarning": "Genug Vorrat, aber {{meds}} wird knapp.",
|
||||
"lowWarning_other": "Genug Vorrat, aber {{meds}} werden knapp.",
|
||||
"lowWarningPrefix": "Genug Vorrat, aber",
|
||||
"lowWarningSuffix": "wird knapp.",
|
||||
"lowWarningSuffix_other": "werden knapp.",
|
||||
"sendReminder": "🔔 Erinnerung jetzt senden"
|
||||
},
|
||||
"overview": {
|
||||
"title": "Medikamentenübersicht",
|
||||
@@ -37,6 +42,10 @@
|
||||
"hidePastDays": "Vergangene Tage ausblenden",
|
||||
"pastDaysCount": "{{count}} Tag",
|
||||
"pastDaysCount_other": "{{count}} Tage",
|
||||
"showFutureDays": "Zukünftige Tage anzeigen",
|
||||
"hideFutureDays": "Zukünftige Tage ausblenden",
|
||||
"futureDaysCount": "{{count}} Tag",
|
||||
"futureDaysCount_other": "{{count}} Tage",
|
||||
"missedDoses": "{{count}} verpasste Dosis",
|
||||
"missedDoses_other": "{{count}} verpasste Dosen",
|
||||
"clearMissed": "Verpasste löschen",
|
||||
@@ -49,26 +58,38 @@
|
||||
},
|
||||
"reminders": {
|
||||
"active": "Automatische Erinnerungen aktiv",
|
||||
"status": "Status",
|
||||
"allStockOk": "Bestand OK",
|
||||
"allOk": "✓ Alles OK",
|
||||
"lastReminder": "Letzte Erinnerung",
|
||||
"lastSent": "Zuletzt gesendet",
|
||||
"next": "Nächste",
|
||||
"nextIn": "Nächste",
|
||||
"allOk": "Alles OK",
|
||||
"lastReminder": "Letzte Einnahme-Erinnerung",
|
||||
"lastSent": "Letzte Einnahme-Erinnerung",
|
||||
"next": "Nachbestell-Erinnerung",
|
||||
"nextIn": "Nachbestell-Erinnerung",
|
||||
"inDays": "in {{days}} Tagen",
|
||||
"noRemindersNeeded": "keine Erinnerungen nötig",
|
||||
"needReorder": "{{count}} Medikament nachbestellen",
|
||||
"needReorder_other": "{{count}} Medikamente nachbestellen",
|
||||
"inDays_one": "in {{days}} Tag",
|
||||
"inDays_other": "in {{days}} Tagen",
|
||||
"noRemindersNeeded": "Keine Erinnerungen nötig",
|
||||
"needRefill": "{{count}} Medikament nachfüllen",
|
||||
"needRefill_other": "{{count}} Medikamente nachfüllen",
|
||||
"emptyStock": "{{count}} Medikament leer",
|
||||
"emptyStock_other": "{{count}} Medikamente leer",
|
||||
"lowWarning": "{{count}} Medikament wird knapp",
|
||||
"lowWarning_other": "{{count}} Medikamente werden knapp",
|
||||
"waitingFirstCheck": "warte auf erste Prüfung",
|
||||
"waitingFirstCheck": "Warte auf erste Prüfung",
|
||||
"type": "Typ",
|
||||
"typeStock": "Bestand",
|
||||
"typeIntake": "Einnahme",
|
||||
"via": "via",
|
||||
"channelEmail": "E-Mail",
|
||||
"channelPush": "Push",
|
||||
"channelBoth": "E-Mail + Push"
|
||||
"channelBoth": "E-Mail + Push",
|
||||
"criticalMeds": "{{count}} Medikament kritisch",
|
||||
"criticalMeds_other": "{{count}} Medikamente kritisch",
|
||||
"lowMeds": "{{count}} Medikament knapp",
|
||||
"lowMeds_other": "{{count}} Medikamente knapp",
|
||||
"daysLeft": "{{days}} Tag übrig",
|
||||
"daysLeft_other": "{{days}} Tage übrig",
|
||||
"needsRefill": "Nachfüllen nötig"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
@@ -78,11 +99,16 @@
|
||||
"currentPills": "Aktuelle Tabletten",
|
||||
"fullBlisters": "Volle Blister",
|
||||
"openBlister": "Offener Blister",
|
||||
"stock": "Bestand",
|
||||
"stockDetails": "Details",
|
||||
"daysLeft": "Tage übrig",
|
||||
"status": "Bestand",
|
||||
"status": "Status",
|
||||
"runsOut": "Aufgebraucht",
|
||||
"autoRemind": "Auto-Erinnerung",
|
||||
"expiry": "Ablaufdatum"
|
||||
"expiry": "Ablaufdatum",
|
||||
"pillsCount": "{{count}} Tabletten",
|
||||
"pillsCount_one": "{{count}} Tablette",
|
||||
"pillsCount_other": "{{count}} Tabletten"
|
||||
},
|
||||
"medications": {
|
||||
"list": {
|
||||
@@ -96,7 +122,8 @@
|
||||
"blisters": "Blister pro Packung",
|
||||
"pillsPerBlister": "Tabletten pro Blister",
|
||||
"loose": "Lose",
|
||||
"total": "Gesamt"
|
||||
"total": "Gesamt",
|
||||
"stock": "Bestand"
|
||||
}
|
||||
},
|
||||
"form": {
|
||||
@@ -106,11 +133,16 @@
|
||||
"commercialName": "Handelsname",
|
||||
"genericName": "Wirkstoff",
|
||||
"takenBy": "Eingenommen von",
|
||||
"packageType": "Verpackungsart",
|
||||
"packageTypeBlister": "Blisterpackung",
|
||||
"packageTypeBottle": "Pillendose / Behälter",
|
||||
"packs": "Packungen",
|
||||
"blistersPerPack": "Blister pro Packung",
|
||||
"pillsPerBlister": "Tabletten pro Blister",
|
||||
"totalCapacity": "Gesamtkapazität",
|
||||
"currentPills": "Aktuelle Tabletten",
|
||||
"loosePills": "Lose Tabletten",
|
||||
"pillWeight": "Tablettengewicht (mg)",
|
||||
"pillWeight": "Dosis pro Tablette",
|
||||
"total": "Gesamt (Tabletten)",
|
||||
"expiryDate": "Ablaufdatum",
|
||||
"notes": "Notizen",
|
||||
@@ -134,7 +166,9 @@
|
||||
"every": "alle",
|
||||
"from": "ab",
|
||||
"startDate": "Datum",
|
||||
"startTime": "Uhrzeit"
|
||||
"startTime": "Uhrzeit",
|
||||
"takenByIntake": "Eingenommen von",
|
||||
"takenByEveryone": "Alle"
|
||||
}
|
||||
},
|
||||
"planner": {
|
||||
@@ -240,6 +274,7 @@
|
||||
},
|
||||
"status": {
|
||||
"outOfStock": "Leer",
|
||||
"criticalStock": "Kritisch",
|
||||
"lowStock": "Niedrig",
|
||||
"normal": "Normal",
|
||||
"highStock": "Hoch",
|
||||
@@ -296,12 +331,22 @@
|
||||
"avatarUpdated": "Avatar aktualisiert",
|
||||
"avatarRemoved": "Avatar entfernt",
|
||||
"loginWithSSO": "Mit {{provider}} anmelden",
|
||||
"or": "oder"
|
||||
"or": "oder",
|
||||
"deleteAccount": "Konto löschen",
|
||||
"deleteAccountConfirmTitle": "Konto löschen?",
|
||||
"deleteAccountConfirmText": "Dadurch werden dein Konto und alle deine Daten (Medikamente, Einstellungen, Verlauf) dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"deleteAccountButton": "Ja, mein Konto löschen"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Wird geladen...",
|
||||
"sending": "Wird gesendet...",
|
||||
"saving": "Wird gespeichert...",
|
||||
"unsavedChanges": {
|
||||
"title": "Ungespeicherte Änderungen",
|
||||
"message": "Du hast ungespeicherte Änderungen. Möchtest du die Seite wirklich verlassen?",
|
||||
"leave": "Verlassen",
|
||||
"stay": "Bleiben"
|
||||
},
|
||||
"validation": {
|
||||
"required": "Dieses Feld ist erforderlich",
|
||||
"maxLength": "Maximal {{max}} Zeichen ({{current}}/{{max}})",
|
||||
@@ -391,7 +436,7 @@
|
||||
"importSuccess": "Daten erfolgreich importiert",
|
||||
"importSuccessDetails": "Importiert: {{medications}} Medikamente, {{doses}} Dosen, {{shares}} Teilen-Links",
|
||||
"importError": "Daten konnten nicht importiert werden",
|
||||
"invalidFile": "Ungültiges Dateiformat. Bitte wähle eine gültige MedAssist-Exportdatei.",
|
||||
"invalidFile": "Ungültiges Dateiformat. Bitte wähle eine gültige MedAssist-ng-Exportdatei.",
|
||||
"downloadFilename": "medassist-export"
|
||||
},
|
||||
"refill": {
|
||||
|
||||
+63
-20
@@ -17,12 +17,15 @@
|
||||
},
|
||||
"dashboard": {
|
||||
"reorder": {
|
||||
"title": "Reorder Reminder",
|
||||
"title": "Refill Reminder",
|
||||
"badge": "Stock watch",
|
||||
"noMeds": "No medications configured yet.",
|
||||
"allGood": "All good, enough stock.",
|
||||
"lowWarning": "Enough stock for now, but {{count}} medication is running low.",
|
||||
"lowWarning_other": "Enough stock for now, but {{count}} medications are running low.",
|
||||
"lowWarning": "Enough stock for now, but {{meds}} is running low.",
|
||||
"lowWarning_other": "Enough stock for now, but {{meds}} are running low.",
|
||||
"lowWarningPrefix": "Enough stock for now, but",
|
||||
"lowWarningSuffix": "is running low.",
|
||||
"lowWarningSuffix_other": "are running low.",
|
||||
"sendReminder": "🔔 Send Reminder Now"
|
||||
},
|
||||
"overview": {
|
||||
@@ -39,6 +42,10 @@
|
||||
"hidePastDays": "Hide past days",
|
||||
"pastDaysCount": "{{count}} day",
|
||||
"pastDaysCount_other": "{{count}} days",
|
||||
"showFutureDays": "Show future days",
|
||||
"hideFutureDays": "Hide future days",
|
||||
"futureDaysCount": "{{count}} day",
|
||||
"futureDaysCount_other": "{{count}} days",
|
||||
"missedDoses": "{{count}} missed dose",
|
||||
"missedDoses_other": "{{count}} missed doses",
|
||||
"clearMissed": "Clear missed",
|
||||
@@ -51,26 +58,38 @@
|
||||
},
|
||||
"reminders": {
|
||||
"active": "Automatic reminders active",
|
||||
"status": "Status",
|
||||
"allStockOk": "All stock OK",
|
||||
"allOk": "✓ All OK",
|
||||
"lastReminder": "Last reminder",
|
||||
"lastSent": "Last sent",
|
||||
"next": "Next",
|
||||
"nextIn": "Next",
|
||||
"allOk": "All OK",
|
||||
"lastReminder": "Last intake reminder",
|
||||
"lastSent": "Last intake reminder",
|
||||
"next": "Refill reminder",
|
||||
"nextIn": "Refill reminder",
|
||||
"inDays": "in {{days}} days",
|
||||
"noRemindersNeeded": "no reminders needed",
|
||||
"needReorder": "{{count}} med needs reorder",
|
||||
"needReorder_other": "{{count}} meds need reorder",
|
||||
"inDays_one": "in {{days}} day",
|
||||
"inDays_other": "in {{days}} days",
|
||||
"noRemindersNeeded": "No reminders needed",
|
||||
"needRefill": "{{count}} med needs refill",
|
||||
"needRefill_other": "{{count}} meds need refill",
|
||||
"emptyStock": "{{count}} med is empty",
|
||||
"emptyStock_other": "{{count}} meds are empty",
|
||||
"lowWarning": "{{count}} medication running low",
|
||||
"lowWarning_other": "{{count}} medications running low",
|
||||
"waitingFirstCheck": "waiting for first check",
|
||||
"waitingFirstCheck": "Waiting for first check",
|
||||
"type": "Type",
|
||||
"typeStock": "Stock",
|
||||
"typeIntake": "Intake",
|
||||
"via": "via",
|
||||
"channelEmail": "Email",
|
||||
"channelPush": "Push",
|
||||
"channelBoth": "Email + Push"
|
||||
"channelBoth": "Email + Push",
|
||||
"criticalMeds": "{{count}} medication critical",
|
||||
"criticalMeds_other": "{{count}} medications critical",
|
||||
"lowMeds": "{{count}} medication low",
|
||||
"lowMeds_other": "{{count}} medications low",
|
||||
"daysLeft": "{{days}} day left",
|
||||
"daysLeft_other": "{{days}} days left",
|
||||
"needsRefill": "Needs refill"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
@@ -80,11 +99,16 @@
|
||||
"currentPills": "Current pills",
|
||||
"fullBlisters": "Full blisters",
|
||||
"openBlister": "Open blister",
|
||||
"stock": "Stock",
|
||||
"stockDetails": "Details",
|
||||
"daysLeft": "Days left",
|
||||
"status": "Stock",
|
||||
"status": "Status",
|
||||
"runsOut": "Runs out",
|
||||
"autoRemind": "Auto-remind",
|
||||
"expiry": "Expiry"
|
||||
"expiry": "Expiry",
|
||||
"pillsCount": "{{count}} pills",
|
||||
"pillsCount_one": "{{count}} pill",
|
||||
"pillsCount_other": "{{count}} pills"
|
||||
},
|
||||
"medications": {
|
||||
"list": {
|
||||
@@ -98,7 +122,8 @@
|
||||
"blisters": "Blisters per pack",
|
||||
"pillsPerBlister": "Pills per blister",
|
||||
"loose": "Loose",
|
||||
"total": "Total"
|
||||
"total": "Total",
|
||||
"stock": "Stock"
|
||||
}
|
||||
},
|
||||
"form": {
|
||||
@@ -108,11 +133,16 @@
|
||||
"commercialName": "Commercial Name",
|
||||
"genericName": "Generic Name",
|
||||
"takenBy": "Taken by",
|
||||
"packageType": "Package Type",
|
||||
"packageTypeBlister": "Blister Pack",
|
||||
"packageTypeBottle": "Pill Bottle / Container",
|
||||
"packs": "Packs",
|
||||
"blistersPerPack": "Blisters per pack",
|
||||
"pillsPerBlister": "Pills per blister",
|
||||
"totalCapacity": "Total Capacity",
|
||||
"currentPills": "Current Pills",
|
||||
"loosePills": "Loose pills",
|
||||
"pillWeight": "Pill weight (mg)",
|
||||
"pillWeight": "Dose per pill",
|
||||
"total": "Total (pills)",
|
||||
"expiryDate": "Expiry Date",
|
||||
"notes": "Notes",
|
||||
@@ -136,7 +166,9 @@
|
||||
"every": "every",
|
||||
"from": "from",
|
||||
"startDate": "Date",
|
||||
"startTime": "Time"
|
||||
"startTime": "Time",
|
||||
"takenByIntake": "Taken by",
|
||||
"takenByEveryone": "Everyone"
|
||||
}
|
||||
},
|
||||
"planner": {
|
||||
@@ -242,6 +274,7 @@
|
||||
},
|
||||
"status": {
|
||||
"outOfStock": "Empty",
|
||||
"criticalStock": "Critical",
|
||||
"lowStock": "Low",
|
||||
"normal": "Normal",
|
||||
"highStock": "High",
|
||||
@@ -298,12 +331,22 @@
|
||||
"avatarUpdated": "Avatar updated",
|
||||
"avatarRemoved": "Avatar removed",
|
||||
"loginWithSSO": "Login with {{provider}}",
|
||||
"or": "or"
|
||||
"or": "or",
|
||||
"deleteAccount": "Delete Account",
|
||||
"deleteAccountConfirmTitle": "Delete Account?",
|
||||
"deleteAccountConfirmText": "This will permanently delete your account and all your data (medications, settings, history). This action cannot be undone.",
|
||||
"deleteAccountButton": "Yes, delete my account"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Loading...",
|
||||
"sending": "Sending...",
|
||||
"saving": "Saving...",
|
||||
"unsavedChanges": {
|
||||
"title": "Unsaved Changes",
|
||||
"message": "You have unsaved changes. Are you sure you want to leave?",
|
||||
"leave": "Leave",
|
||||
"stay": "Stay"
|
||||
},
|
||||
"validation": {
|
||||
"required": "This field is required",
|
||||
"maxLength": "Maximum {{max}} characters ({{current}}/{{max}})",
|
||||
@@ -393,7 +436,7 @@
|
||||
"importSuccess": "Data imported successfully",
|
||||
"importSuccessDetails": "Imported: {{medications}} medications, {{doses}} doses, {{shares}} share links",
|
||||
"importError": "Failed to import data",
|
||||
"invalidFile": "Invalid file format. Please select a valid MedAssist export file.",
|
||||
"invalidFile": "Invalid file format. Please select a valid MedAssist-ng export file.",
|
||||
"downloadFilename": "medassist-export"
|
||||
},
|
||||
"refill": {
|
||||
|
||||
+22
-23
@@ -1,30 +1,29 @@
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
|
||||
import en from './en.json';
|
||||
import de from './de.json';
|
||||
import i18n from "i18next";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import de from "./de.json";
|
||||
import en from "./en.json";
|
||||
|
||||
const resources = {
|
||||
en: { translation: en },
|
||||
de: { translation: de },
|
||||
en: { translation: en },
|
||||
de: { translation: de },
|
||||
};
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources,
|
||||
fallbackLng: 'en',
|
||||
supportedLngs: ['en', 'de'],
|
||||
interpolation: {
|
||||
escapeValue: false, // React already escapes
|
||||
},
|
||||
detection: {
|
||||
order: ['localStorage', 'navigator'],
|
||||
caches: ['localStorage'],
|
||||
lookupLocalStorage: 'medassist-ng-language',
|
||||
},
|
||||
});
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources,
|
||||
fallbackLng: "en",
|
||||
supportedLngs: ["en", "de"],
|
||||
interpolation: {
|
||||
escapeValue: false, // React already escapes
|
||||
},
|
||||
detection: {
|
||||
order: ["localStorage", "navigator"],
|
||||
caches: ["localStorage"],
|
||||
lookupLocalStorage: "medassist-ng-language",
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
|
||||
@@ -6,9 +6,9 @@ import "./styles.css";
|
||||
import "./i18n";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,16 @@
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppContext } from "../context";
|
||||
import { MedicationAvatar, MobileEditModal } from "../components";
|
||||
import { useMedicationForm } from "../hooks";
|
||||
import { formatNumber, formatDateTime, combineDateAndTime } from "../utils/formatters";
|
||||
import { getPackageSize, FIELD_LIMITS } from "../types";
|
||||
import type { Medication } from "../types";
|
||||
import { ConfirmModal, MedicationAvatar, MobileEditModal } from "../components";
|
||||
import { useAppContext, useUnsavedChanges } from "../context";
|
||||
import { useMedicationForm, useUnsavedChangesWarning } from "../hooks";
|
||||
import type { DoseUnit, Medication } from "../types";
|
||||
import { DOSE_UNITS, FIELD_LIMITS, getPackageSize } from "../types";
|
||||
import { combineDateAndTime, formatDateTime, formatNumber } from "../utils/formatters";
|
||||
|
||||
export function MedicationsPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
meds,
|
||||
loading,
|
||||
saving,
|
||||
setSaving,
|
||||
loadMeds,
|
||||
@@ -26,6 +25,7 @@ export function MedicationsPage() {
|
||||
setRefillLoose,
|
||||
refillSaving,
|
||||
submitRefill,
|
||||
coverageByMed,
|
||||
} = useAppContext();
|
||||
|
||||
// Use the medication form hook
|
||||
@@ -34,7 +34,6 @@ export function MedicationsPage() {
|
||||
setForm,
|
||||
setOriginalForm,
|
||||
editingId,
|
||||
setEditingId,
|
||||
formSaved,
|
||||
setFormSaved,
|
||||
formChanged,
|
||||
@@ -49,42 +48,111 @@ export function MedicationsPage() {
|
||||
addBlister,
|
||||
removeBlister,
|
||||
setBlisterValue,
|
||||
addIntake,
|
||||
removeIntake,
|
||||
setIntakeValue,
|
||||
resetForm,
|
||||
startEdit,
|
||||
} = useMedicationForm();
|
||||
|
||||
// Warn user about unsaved changes when navigating away
|
||||
useUnsavedChangesWarning(formChanged);
|
||||
|
||||
// Mobile modal state (declared early because it's used in useEffect below)
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
|
||||
// Sync formChanged state to the global context for navigation blocking
|
||||
const { setHasUnsavedChanges } = useUnsavedChanges();
|
||||
useEffect(() => {
|
||||
setHasUnsavedChanges(formChanged);
|
||||
return () => setHasUnsavedChanges(false); // Clear on unmount
|
||||
}, [formChanged, setHasUnsavedChanges]);
|
||||
|
||||
// Push history state when form changes to capture browser back button
|
||||
const hasUnsavedHistoryState = useRef(false);
|
||||
useEffect(() => {
|
||||
if (formChanged && !hasUnsavedHistoryState.current && !showEditModal) {
|
||||
// Push a history state so we can intercept browser back
|
||||
window.history.pushState({ unsavedChanges: true }, "");
|
||||
hasUnsavedHistoryState.current = true;
|
||||
} else if (!formChanged && hasUnsavedHistoryState.current) {
|
||||
// Clean up history state when form is saved/reset
|
||||
hasUnsavedHistoryState.current = false;
|
||||
}
|
||||
}, [formChanged, showEditModal]);
|
||||
|
||||
// Image state for new medications
|
||||
const [pendingImage, setPendingImage] = useState<File | null>(null);
|
||||
const [pendingImagePreview, setPendingImagePreview] = useState<string | null>(null);
|
||||
|
||||
// Mobile modal state
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
// Track if close was confirmed programmatically (to avoid double confirmation)
|
||||
const closeConfirmedRef = useRef(false);
|
||||
// Confirmation modal for unsaved changes
|
||||
const [showUnsavedConfirm, setShowUnsavedConfirm] = useState(false);
|
||||
|
||||
// Calculate total tablets
|
||||
const totalTablets = useMemo(() => {
|
||||
if (form.packageType === "bottle") {
|
||||
// For bottle type, looseTablets is the current stock
|
||||
return Number(form.looseTablets) || 0;
|
||||
}
|
||||
// For blister type
|
||||
const packCount = Number(form.packCount) || 0;
|
||||
const blistersPerPack = Number(form.blistersPerPack) || 0;
|
||||
const pillsPerBlister = Number(form.pillsPerBlister) || 1;
|
||||
const looseTablets = Number(form.looseTablets) || 0;
|
||||
return packCount * blistersPerPack * pillsPerBlister + looseTablets;
|
||||
}, [form.packCount, form.blistersPerPack, form.pillsPerBlister, form.looseTablets]);
|
||||
}, [form.packageType, form.packCount, form.blistersPerPack, form.pillsPerBlister, form.looseTablets]);
|
||||
|
||||
// Open mobile edit modal
|
||||
function openEditModal() {
|
||||
setShowEditModal(true);
|
||||
window.history.pushState({ modal: 'edit' }, '');
|
||||
window.history.pushState({ modal: "edit" }, "");
|
||||
}
|
||||
|
||||
// Close mobile edit modal
|
||||
function closeEditModal() {
|
||||
if (showEditModal) {
|
||||
// Check for unsaved changes before closing
|
||||
if (formChanged) {
|
||||
setShowUnsavedConfirm(true);
|
||||
return;
|
||||
}
|
||||
// Mark as confirmed to avoid double confirmation in popstate handler
|
||||
closeConfirmedRef.current = true;
|
||||
window.history.back();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle confirmed close (user clicked "Leave" in confirmation modal)
|
||||
function handleConfirmClose() {
|
||||
setShowUnsavedConfirm(false);
|
||||
closeConfirmedRef.current = true;
|
||||
hasUnsavedHistoryState.current = false;
|
||||
if (showEditModal) {
|
||||
setShowEditModal(false);
|
||||
}
|
||||
resetForm();
|
||||
window.history.back();
|
||||
}
|
||||
|
||||
// Handle cancelled close (user clicked "Stay" in confirmation modal)
|
||||
function handleCancelClose() {
|
||||
setShowUnsavedConfirm(false);
|
||||
}
|
||||
|
||||
// Helper to reset form and clear history state
|
||||
function handleResetForm() {
|
||||
if (hasUnsavedHistoryState.current) {
|
||||
hasUnsavedHistoryState.current = false;
|
||||
// Go back to remove the unsaved changes history entry
|
||||
window.history.back();
|
||||
}
|
||||
resetForm();
|
||||
}
|
||||
|
||||
// Handle delete medication
|
||||
async function handleDeleteMed(id: number) {
|
||||
if (!confirm(t('medications.deleteConfirm'))) return;
|
||||
if (!confirm(t("medications.deleteConfirm"))) return;
|
||||
await deleteMed(id, editingId, resetForm);
|
||||
}
|
||||
|
||||
@@ -99,26 +167,39 @@ export function MedicationsPage() {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
|
||||
// Prepare medication data
|
||||
const blisters = form.blisters.map(b => ({
|
||||
usage: Number(b.usage) || 1,
|
||||
every: Number(b.every) || 1,
|
||||
start: combineDateAndTime(b.startDate, b.startTime),
|
||||
// Prepare intakes data with per-intake takenBy
|
||||
const intakes = form.intakes.map((intake) => ({
|
||||
usage: Number(intake.usage) || 1,
|
||||
every: Number(intake.every) || 1,
|
||||
start: combineDateAndTime(intake.startDate, intake.startTime),
|
||||
takenBy: intake.takenBy.trim() || null, // Empty string becomes null
|
||||
intakeRemindersEnabled: intake.intakeRemindersEnabled,
|
||||
}));
|
||||
|
||||
// Also prepare legacy blisters for backward compatibility
|
||||
const blisters = intakes.map((i) => ({
|
||||
usage: i.usage,
|
||||
every: i.every,
|
||||
start: i.start,
|
||||
}));
|
||||
|
||||
const body = {
|
||||
name: form.name.trim(),
|
||||
genericName: form.genericName.trim() || null,
|
||||
takenBy: form.takenBy.length > 0 ? form.takenBy : [],
|
||||
packageType: form.packageType,
|
||||
packCount: Number(form.packCount) || 0,
|
||||
blistersPerPack: Number(form.blistersPerPack) || 1,
|
||||
pillsPerBlister: Number(form.pillsPerBlister) || 1,
|
||||
totalPills: Number(form.totalPills) || null,
|
||||
looseTablets: Number(form.looseTablets) || 0,
|
||||
pillWeightMg: Number(form.pillWeightMg) || null,
|
||||
doseUnit: form.doseUnit,
|
||||
expiryDate: form.expiryDate || null,
|
||||
notes: form.notes.trim() || null,
|
||||
intakeRemindersEnabled: form.intakeRemindersEnabled,
|
||||
blisters,
|
||||
blisters, // Legacy format for backward compatibility
|
||||
intakes, // New format with per-intake takenBy
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -133,6 +214,7 @@ export function MedicationsPage() {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -151,6 +233,12 @@ export function MedicationsPage() {
|
||||
setFormSaved(true);
|
||||
loadMeds();
|
||||
|
||||
// Clean up history state if we had unsaved changes
|
||||
if (hasUnsavedHistoryState.current) {
|
||||
hasUnsavedHistoryState.current = false;
|
||||
// Don't go back here, just clear the flag - the state will be cleaned naturally
|
||||
}
|
||||
|
||||
// Reset form after successful save
|
||||
if (!editingId) {
|
||||
resetForm();
|
||||
@@ -160,34 +248,62 @@ export function MedicationsPage() {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Save error:", err);
|
||||
alert(t('common.saveFailed'));
|
||||
alert(t("common.saveFailed"));
|
||||
}
|
||||
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
// Handle browser back button for modals
|
||||
// Handle browser back button for modals and unsaved changes
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
// If close was already confirmed programmatically, allow navigation
|
||||
if (closeConfirmedRef.current) {
|
||||
closeConfirmedRef.current = false;
|
||||
if (showEditModal) {
|
||||
setShowEditModal(false);
|
||||
resetForm();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle mobile edit modal
|
||||
if (showEditModal) {
|
||||
// Check for unsaved changes (user pressed browser back directly)
|
||||
if (formChanged) {
|
||||
// Re-push history state to stay in modal
|
||||
window.history.pushState({ modal: "edit" }, "");
|
||||
// Show confirmation modal
|
||||
setShowUnsavedConfirm(true);
|
||||
return;
|
||||
}
|
||||
setShowEditModal(false);
|
||||
resetForm();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle desktop form with unsaved changes
|
||||
if (formChanged && hasUnsavedHistoryState.current) {
|
||||
// Re-push history state to stay on page
|
||||
window.history.pushState({ unsavedChanges: true }, "");
|
||||
// Show confirmation modal
|
||||
setShowUnsavedConfirm(true);
|
||||
}
|
||||
};
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, [showEditModal]);
|
||||
window.addEventListener("popstate", handlePopState);
|
||||
return () => window.removeEventListener("popstate", handlePopState);
|
||||
}, [showEditModal, formChanged, resetForm]);
|
||||
|
||||
// Close modal on Escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && showEditModal) {
|
||||
closeEditModal();
|
||||
resetForm();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [showEditModal]);
|
||||
}, [showEditModal, closeEditModal]);
|
||||
|
||||
// Handle edit button click - open modal on mobile
|
||||
function handleEditClick(med: Medication) {
|
||||
@@ -198,10 +314,10 @@ export function MedicationsPage() {
|
||||
<section className="grid">
|
||||
<article className="card meds">
|
||||
<div className="card-head">
|
||||
<h2>{t('medications.list.title')}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary small"
|
||||
<h2>{t("medications.list.title")}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary small"
|
||||
onClick={() => {
|
||||
resetForm();
|
||||
// On mobile, open the edit modal
|
||||
@@ -210,12 +326,12 @@ export function MedicationsPage() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
+ {t('form.newEntry')}
|
||||
+ {t("form.newEntry")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="med-list">
|
||||
{meds.map((med) => (
|
||||
<div key={med.id} className="med-row">
|
||||
<div key={med.id} className={`med-row${editingId === med.id ? " editing" : ""}`}>
|
||||
<div className="med-header">
|
||||
<div className="med-info">
|
||||
<div className="med-name-row">
|
||||
@@ -223,22 +339,40 @@ export function MedicationsPage() {
|
||||
<div className="med-name">{med.name}</div>
|
||||
</div>
|
||||
<div className="med-details">
|
||||
<span>{t('medications.details.packs')}: <strong>{med.packCount}</strong></span>
|
||||
<span>{t('medications.details.blisters')}: <strong>{med.blistersPerPack}</strong></span>
|
||||
<span>{t('medications.details.pillsPerBlister')}: <strong>{med.pillsPerBlister}</strong></span>
|
||||
<span>{t('medications.details.loose')}: <strong>{med.looseTablets}</strong></span>
|
||||
<span>
|
||||
{t("medications.details.packs")}: <strong>{med.packCount}</strong>
|
||||
</span>
|
||||
<span>
|
||||
{t("medications.details.blisters")}: <strong>{med.blistersPerPack}</strong>
|
||||
</span>
|
||||
<span>
|
||||
{t("medications.details.pillsPerBlister")}: <strong>{med.pillsPerBlister}</strong>
|
||||
</span>
|
||||
<span>
|
||||
{t("medications.details.loose")}: <strong>{med.looseTablets}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div className="med-total">
|
||||
{t("medications.details.stock")}:{" "}
|
||||
{coverageByMed[med.name] ? Math.round(coverageByMed[med.name].medsLeft) : getPackageSize(med)} /{" "}
|
||||
{getPackageSize(med)} {t("common.pills")}
|
||||
</div>
|
||||
<div className="med-total">{t('medications.details.total')}: {getPackageSize(med)} {t('common.pills')}</div>
|
||||
</div>
|
||||
<div className="med-actions">
|
||||
<button className="info" onClick={() => handleEditClick(med)}>{t('common.edit')}</button>
|
||||
<button className="danger" onClick={() => handleDeleteMed(med.id)}>{t('common.delete')}</button>
|
||||
<button className="info" onClick={() => handleEditClick(med)}>
|
||||
{t("common.edit")}
|
||||
</button>
|
||||
<button className="danger" onClick={() => handleDeleteMed(med.id)}>
|
||||
{t("common.delete")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="blister-list">
|
||||
{med.blisters.map((s, idx) => (
|
||||
<div key={`${med.id}-${idx}`} className="blister-row-simple">
|
||||
{s.usage} {s.usage === 1 ? t('common.pill') : t('common.pills')} · {t('form.blisters.every')} {s.every} {s.every === 1 ? t('common.day') : t('common.days')} · {t('form.blisters.from')} {formatDateTime(s.start)}
|
||||
{s.usage} {s.usage === 1 ? t("common.pill") : t("common.pills")} · {t("form.blisters.every")}{" "}
|
||||
{s.every} {s.every === 1 ? t("common.day") : t("common.days")} · {t("form.blisters.from")}{" "}
|
||||
{formatDateTime(s.start)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -249,106 +383,208 @@ export function MedicationsPage() {
|
||||
|
||||
<article className="card form desktop-only">
|
||||
<div className="card-head">
|
||||
<h2>{editingId ? t('form.editEntry') : t('form.newEntry')}</h2>
|
||||
{editingId ? (
|
||||
<div className="edit-header">
|
||||
<MedicationAvatar
|
||||
name={meds.find((m) => m.id === editingId)?.name || ""}
|
||||
imageUrl={meds.find((m) => m.id === editingId)?.imageUrl}
|
||||
size="md"
|
||||
/>
|
||||
<h2>
|
||||
{t("form.editEntry")}: {meds.find((m) => m.id === editingId)?.name}
|
||||
</h2>
|
||||
</div>
|
||||
) : (
|
||||
<h2>{t("form.newEntry")}</h2>
|
||||
)}
|
||||
</div>
|
||||
<form className="form-grid" onSubmit={saveMedication}>
|
||||
<label className={fieldErrors.name ? 'has-error' : ''}>
|
||||
{t('form.commercialName')}
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder={t('form.placeholders.commercial')}
|
||||
<label className={fieldErrors.name ? "has-error" : ""}>
|
||||
{t("form.commercialName")}
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder={t("form.placeholders.commercial")}
|
||||
maxLength={FIELD_LIMITS.name.max}
|
||||
required
|
||||
required
|
||||
/>
|
||||
{fieldErrors.name && <span className="field-error">{fieldErrors.name}</span>}
|
||||
</label>
|
||||
<label className={fieldErrors.genericName ? 'has-error' : ''}>
|
||||
{t('form.genericName')}
|
||||
<input
|
||||
value={form.genericName}
|
||||
onChange={(e) => setForm({ ...form, genericName: e.target.value })}
|
||||
placeholder={t('form.placeholders.generic')}
|
||||
<label className={fieldErrors.genericName ? "has-error" : ""}>
|
||||
{t("form.genericName")}
|
||||
<input
|
||||
value={form.genericName}
|
||||
onChange={(e) => setForm({ ...form, genericName: e.target.value })}
|
||||
placeholder={t("form.placeholders.generic")}
|
||||
maxLength={FIELD_LIMITS.genericName.max}
|
||||
/>
|
||||
{fieldErrors.genericName && <span className="field-error">{fieldErrors.genericName}</span>}
|
||||
</label>
|
||||
<label className={fieldErrors.takenBy ? 'has-error' : ''}>
|
||||
{t('form.takenBy')}
|
||||
<label className={fieldErrors.takenBy ? "has-error" : ""}>
|
||||
{t("form.takenBy")}
|
||||
<div className="tag-input-container">
|
||||
{form.takenBy.map((person) => (
|
||||
<span key={person} className="tag">
|
||||
{person}
|
||||
<button type="button" className="tag-remove" onClick={() => removeTakenByPerson(person)}>×</button>
|
||||
<button type="button" className="tag-remove" onClick={() => removeTakenByPerson(person)}>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
value={takenByInput}
|
||||
onChange={(e) => setTakenByInput(e.target.value)}
|
||||
<input
|
||||
value={takenByInput}
|
||||
onChange={(e) => setTakenByInput(e.target.value)}
|
||||
onKeyDown={handleTakenByKeyDown}
|
||||
onBlur={() => { if (takenByInput.trim()) addTakenByPerson(takenByInput); }}
|
||||
placeholder={form.takenBy.length === 0 ? t('form.placeholders.takenBy') : t('form.placeholders.addPerson')}
|
||||
onBlur={() => {
|
||||
if (takenByInput.trim()) addTakenByPerson(takenByInput);
|
||||
}}
|
||||
placeholder={
|
||||
form.takenBy.length === 0 ? t("form.placeholders.takenBy") : t("form.placeholders.addPerson")
|
||||
}
|
||||
maxLength={FIELD_LIMITS.takenBy.max}
|
||||
list="takenby-suggestions"
|
||||
/>
|
||||
<datalist id="takenby-suggestions">
|
||||
{existingPeople.filter(p => !form.takenBy.includes(p)).map(person => (
|
||||
<option key={person} value={person} />
|
||||
))}
|
||||
{existingPeople
|
||||
.filter((p) => !form.takenBy.includes(p))
|
||||
.map((person) => (
|
||||
<option key={person} value={person} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
{fieldErrors.takenBy && <span className="field-error">{fieldErrors.takenBy}</span>}
|
||||
</label>
|
||||
<label>
|
||||
{t('form.packs')}
|
||||
<input type="number" min="0" value={form.packCount} onChange={(e) => handleValueChange("packCount", e.target.value)} />
|
||||
{t("form.packageType")}
|
||||
<select
|
||||
className="package-type-select"
|
||||
value={form.packageType}
|
||||
onChange={(e) => handleValueChange("packageType", e.target.value)}
|
||||
>
|
||||
<option value="blister">{t("form.packageTypeBlister")}</option>
|
||||
<option value="bottle">{t("form.packageTypeBottle")}</option>
|
||||
</select>
|
||||
</label>
|
||||
{form.packageType === "blister" ? (
|
||||
<>
|
||||
<label>
|
||||
{t("form.packs")}
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={form.packCount}
|
||||
onChange={(e) => handleValueChange("packCount", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.blistersPerPack")}
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={form.blistersPerPack}
|
||||
onChange={(e) => handleValueChange("blistersPerPack", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.pillsPerBlister")}
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={form.pillsPerBlister}
|
||||
onChange={(e) => handleValueChange("pillsPerBlister", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.loosePills")}
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={form.looseTablets}
|
||||
onChange={(e) => handleValueChange("looseTablets", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label>
|
||||
{t("form.totalCapacity")}
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={form.totalPills}
|
||||
onChange={(e) => handleValueChange("totalPills", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.currentPills")}
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={form.looseTablets}
|
||||
onChange={(e) => handleValueChange("looseTablets", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<label>
|
||||
{t("form.pillWeight")} ({form.doseUnit})
|
||||
<div className="dose-input-group">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
value={form.pillWeightMg}
|
||||
onChange={(e) => handleValueChange("pillWeightMg", e.target.value)}
|
||||
placeholder={t("form.placeholders.weight")}
|
||||
/>
|
||||
<select
|
||||
value={form.doseUnit}
|
||||
onChange={(e) => handleValueChange("doseUnit", e.target.value as DoseUnit)}
|
||||
className="dose-unit-select"
|
||||
>
|
||||
{DOSE_UNITS.map((unit) => (
|
||||
<option key={unit.value} value={unit.value}>
|
||||
{unit.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
{t('form.blistersPerPack')}
|
||||
<input type="number" min="1" value={form.blistersPerPack} onChange={(e) => handleValueChange("blistersPerPack", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
{t('form.pillsPerBlister')}
|
||||
<input type="number" min="1" value={form.pillsPerBlister} onChange={(e) => handleValueChange("pillsPerBlister", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
{t('form.loosePills')}
|
||||
<input type="number" min="0" value={form.looseTablets} onChange={(e) => handleValueChange("looseTablets", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
{t('form.pillWeight')}
|
||||
<input type="number" min="1" value={form.pillWeightMg} onChange={(e) => handleValueChange("pillWeightMg", e.target.value)} placeholder={t('form.placeholders.weight')} />
|
||||
</label>
|
||||
<label>
|
||||
{t('form.total')}
|
||||
{t("form.total")}
|
||||
<div className="static-value">{formatNumber(totalTablets)}</div>
|
||||
</label>
|
||||
<label>
|
||||
{t('form.expiryDate')}
|
||||
<input type="date" value={form.expiryDate} onChange={(e) => handleValueChange("expiryDate", e.target.value)} placeholder={t('common.optional')} />
|
||||
{t("form.expiryDate")}
|
||||
<input
|
||||
type="date"
|
||||
value={form.expiryDate}
|
||||
onChange={(e) => handleValueChange("expiryDate", e.target.value)}
|
||||
placeholder={t("common.optional")}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Refill section - only shown when editing */}
|
||||
{editingId && (
|
||||
<div className="full refill-section">
|
||||
<h4 className="refill-title">{t('refill.title')}</h4>
|
||||
<h4 className="refill-title">{t("refill.title")}</h4>
|
||||
<div className="refill-form-inline">
|
||||
<label>
|
||||
{t('refill.packs')}
|
||||
{t("refill.packs")}
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={refillPacks}
|
||||
onChange={(e) => setRefillPacks(parseInt(e.target.value) || 0)}
|
||||
onChange={(e) => setRefillPacks(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t('refill.loosePills')}
|
||||
{t("refill.loosePills")}
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={refillLoose}
|
||||
onChange={(e) => setRefillLoose(parseInt(e.target.value) || 0)}
|
||||
onChange={(e) => setRefillLoose(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
@@ -357,29 +593,36 @@ export function MedicationsPage() {
|
||||
onClick={() => handleSubmitRefill(editingId!)}
|
||||
disabled={(refillPacks < 1 && refillLoose < 1) || refillSaving}
|
||||
>
|
||||
{refillSaving ? t('refill.adding') : t('refill.button')}
|
||||
{refillSaving ? t("refill.adding") : t("refill.button")}
|
||||
</button>
|
||||
{(refillPacks > 0 || refillLoose > 0) && (
|
||||
<span className="refill-preview">+{refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) + refillLoose} {t('common.pills')}</span>
|
||||
<span className="refill-preview">
|
||||
+{refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) + refillLoose}{" "}
|
||||
{t("common.pills")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className={`full ${fieldErrors.notes ? 'has-error' : ''}`}>
|
||||
{t('form.notes')}
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={(e) => handleValueChange("notes", e.target.value)}
|
||||
placeholder={t('form.placeholders.notes')}
|
||||
<label className={`full ${fieldErrors.notes ? "has-error" : ""}`}>
|
||||
{t("form.notes")}
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={(e) => handleValueChange("notes", e.target.value)}
|
||||
placeholder={t("form.placeholders.notes")}
|
||||
rows={2}
|
||||
maxLength={FIELD_LIMITS.notes.max}
|
||||
className="auto-resize"
|
||||
onInput={(e) => { const t = e.target as HTMLTextAreaElement; t.style.height = 'auto'; t.style.height = t.scrollHeight + 'px'; }}
|
||||
onInput={(e) => {
|
||||
const t = e.target as HTMLTextAreaElement;
|
||||
t.style.height = "auto";
|
||||
t.style.height = `${t.scrollHeight}px`;
|
||||
}}
|
||||
/>
|
||||
{form.notes.length > 0 && (
|
||||
<span className={`char-count ${form.notes.length > FIELD_LIMITS.notes.max * 0.9 ? 'warning' : ''}`}>
|
||||
{t('common.validation.tooLong', { current: form.notes.length, max: FIELD_LIMITS.notes.max })}
|
||||
<span className={`char-count ${form.notes.length > FIELD_LIMITS.notes.max * 0.9 ? "warning" : ""}`}>
|
||||
{t("common.validation.tooLong", { current: form.notes.length, max: FIELD_LIMITS.notes.max })}
|
||||
</span>
|
||||
)}
|
||||
{fieldErrors.notes && <span className="field-error">{fieldErrors.notes}</span>}
|
||||
@@ -387,63 +630,99 @@ export function MedicationsPage() {
|
||||
|
||||
<div className="full blisters">
|
||||
<div className="card-head">
|
||||
<h3>{t('form.blisters.title')}</h3>
|
||||
<h3>{t("form.blisters.title")}</h3>
|
||||
<div className="blisters-actions">
|
||||
<label className="inline-checkbox" title={t('form.blisters.remindTooltip')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.intakeRemindersEnabled}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, intakeRemindersEnabled: e.target.checked }))}
|
||||
/>
|
||||
<span>🔔 {t('form.blisters.remind')}</span>
|
||||
</label>
|
||||
<button type="button" className="primary" onClick={addBlister}>+ {t('form.blisters.addIntake')}</button>
|
||||
<button type="button" className="primary" onClick={() => addIntake()}>
|
||||
+ {t("form.blisters.addIntake")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{form.blisters.map((s, idx) => (
|
||||
{form.intakes.map((intake, idx) => (
|
||||
<div key={idx} className="blister-row">
|
||||
<div className="blister-inputs">
|
||||
<label>
|
||||
{t('form.blisters.usage')}
|
||||
<input type="number" min="0" step="0.1" value={s.usage} onChange={(e) => setBlisterValue(idx, "usage", e.target.value)} />
|
||||
{t("form.blisters.usage")}
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
value={intake.usage}
|
||||
onChange={(e) => setIntakeValue(idx, "usage", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t('form.blisters.everyDays')}
|
||||
<input type="number" min="1" value={s.every} onChange={(e) => setBlisterValue(idx, "every", e.target.value)} />
|
||||
{t("form.blisters.everyDays")}
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={intake.every}
|
||||
onChange={(e) => setIntakeValue(idx, "every", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t('form.blisters.startDate')}
|
||||
<input type="date" value={s.startDate} onChange={(e) => setBlisterValue(idx, "startDate", e.target.value)} />
|
||||
{t("form.blisters.startDate")}
|
||||
<input
|
||||
type="date"
|
||||
value={intake.startDate}
|
||||
onChange={(e) => setIntakeValue(idx, "startDate", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t('form.blisters.startTime')}
|
||||
<input type="time" value={s.startTime} onChange={(e) => setBlisterValue(idx, "startTime", e.target.value)} />
|
||||
{t("form.blisters.startTime")}
|
||||
<input
|
||||
type="time"
|
||||
value={intake.startTime}
|
||||
onChange={(e) => setIntakeValue(idx, "startTime", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label title={t("form.blisters.takenByTooltip")}>
|
||||
{t("form.blisters.takenByIntake")}
|
||||
<select value={intake.takenBy} onChange={(e) => setIntakeValue(idx, "takenBy", e.target.value)}>
|
||||
<option value="">{t("form.blisters.takenByEveryone")}</option>
|
||||
{existingPeople.map((person) => (
|
||||
<option key={person} value={person}>
|
||||
{person}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="inline-checkbox" title={t("form.blisters.remindTooltip")}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={intake.intakeRemindersEnabled}
|
||||
onChange={(e) => setIntakeValue(idx, "intakeRemindersEnabled", e.target.checked)}
|
||||
/>
|
||||
<span>🔔</span>
|
||||
</label>
|
||||
</div>
|
||||
{form.blisters.length > 1 && (
|
||||
<button type="button" className="danger" onClick={() => removeBlister(idx)}>{t('common.remove')}</button>
|
||||
{form.intakes.length > 1 && (
|
||||
<button type="button" className="danger" onClick={() => removeIntake(idx)}>
|
||||
{t("common.remove")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="full image-upload-section">
|
||||
<label className="setting-label">{t('form.medicationImage')}</label>
|
||||
<label className="setting-label">{t("form.medicationImage")}</label>
|
||||
{(() => {
|
||||
// When editing an existing medication
|
||||
if (editingId) {
|
||||
const currentMed = meds.find(m => m.id === editingId);
|
||||
const currentMed = meds.find((m) => m.id === editingId);
|
||||
if (currentMed?.imageUrl) {
|
||||
return (
|
||||
<div className="image-preview">
|
||||
<img src={`/api/images/${currentMed.imageUrl}`} alt={currentMed.name} />
|
||||
<button type="button" className="danger" onClick={() => deleteMedImage(editingId)}>{t('form.removeImage')}</button>
|
||||
<button type="button" className="danger" onClick={() => deleteMedImage(editingId)}>
|
||||
{t("form.removeImage")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<input
|
||||
type="file"
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
onChange={(e) => e.target.files?.[0] && uploadMedImage(editingId, e.target.files[0])}
|
||||
disabled={uploadingImage}
|
||||
@@ -455,13 +734,22 @@ export function MedicationsPage() {
|
||||
return (
|
||||
<div className="image-preview">
|
||||
<img src={pendingImagePreview} alt="Preview" />
|
||||
<button type="button" className="danger" onClick={() => { setPendingImage(null); setPendingImagePreview(null); }}>{t('form.removeImage')}</button>
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
onClick={() => {
|
||||
setPendingImage(null);
|
||||
setPendingImagePreview(null);
|
||||
}}
|
||||
>
|
||||
{t("form.removeImage")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<input
|
||||
type="file"
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -479,12 +767,15 @@ export function MedicationsPage() {
|
||||
|
||||
<div className="full align-end gap">
|
||||
{editingId && (
|
||||
<button type="button" className="ghost" onClick={resetForm}>
|
||||
{t('common.cancel')}
|
||||
<button type="button" className="ghost" onClick={handleResetForm}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
)}
|
||||
<button type="submit" disabled={saving || hasValidationErrors || (!formChanged && (formSaved || !!editingId))}>
|
||||
{formSaved && !formChanged ? t('common.saved') : t('common.save')}
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || hasValidationErrors || (!formChanged && (formSaved || !!editingId))}
|
||||
>
|
||||
{formSaved && !formChanged ? t("common.saved") : t("common.save")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -510,6 +801,9 @@ export function MedicationsPage() {
|
||||
onSetBlisterValue={setBlisterValue}
|
||||
onAddBlister={addBlister}
|
||||
onRemoveBlister={removeBlister}
|
||||
onSetIntakeValue={setIntakeValue}
|
||||
onAddIntake={addIntake}
|
||||
onRemoveIntake={removeIntake}
|
||||
onHandleValueChange={handleValueChange}
|
||||
refillPacks={refillPacks}
|
||||
onRefillPacksChange={setRefillPacks}
|
||||
@@ -520,12 +814,25 @@ export function MedicationsPage() {
|
||||
meds={meds}
|
||||
onUploadMedImage={uploadMedImage}
|
||||
onDeleteMedImage={deleteMedImage}
|
||||
onClose={() => { closeEditModal(); }}
|
||||
onResetForm={resetForm}
|
||||
onClose={() => {
|
||||
closeEditModal();
|
||||
}}
|
||||
onResetForm={handleResetForm}
|
||||
onSaveMedication={saveMedication}
|
||||
/>
|
||||
|
||||
{/* Unsaved Changes Confirmation Modal */}
|
||||
{showUnsavedConfirm && (
|
||||
<ConfirmModal
|
||||
title={t("common.unsavedChanges.title", "Unsaved Changes")}
|
||||
message={t("common.unsavedChanges.message")}
|
||||
confirmLabel={t("common.unsavedChanges.leave", "Leave")}
|
||||
cancelLabel={t("common.unsavedChanges.stay", "Stay")}
|
||||
onConfirm={handleConfirmClose}
|
||||
onCancel={handleCancelClose}
|
||||
confirmVariant="danger"
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MedicationAvatar } from "../components";
|
||||
import { useAuth } from "../components/Auth";
|
||||
import { useAppContext } from "../context";
|
||||
import { MedicationAvatar } from "../components";
|
||||
import type { PlannerRow } from "../types";
|
||||
import { toInputValue } from "../utils/formatters";
|
||||
|
||||
@@ -39,7 +39,7 @@ export function PlannerPage() {
|
||||
const [plannerLoading, setPlannerLoading] = useState(false);
|
||||
const [range, setRange] = useState<{ start: string; end: string }>({
|
||||
start: toInputValue(todayIso()),
|
||||
end: toInputValue(plusDaysIso(3))
|
||||
end: toInputValue(plusDaysIso(3)),
|
||||
});
|
||||
const [sendingPlannerEmail, setSendingPlannerEmail] = useState(false);
|
||||
const [plannerEmailResult, setPlannerEmailResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
@@ -49,15 +49,23 @@ export function PlannerPage() {
|
||||
if (typeof window !== "undefined" && user?.id) {
|
||||
const savedRows = localStorage.getItem(userStorageKey(user.id, "plannerRows"));
|
||||
const savedRange = localStorage.getItem(userStorageKey(user.id, "plannerRange"));
|
||||
|
||||
|
||||
if (savedRows) {
|
||||
try { setPlannerRows(JSON.parse(savedRows)); } catch { setPlannerRows([]); }
|
||||
try {
|
||||
setPlannerRows(JSON.parse(savedRows));
|
||||
} catch {
|
||||
setPlannerRows([]);
|
||||
}
|
||||
} else {
|
||||
setPlannerRows([]);
|
||||
}
|
||||
|
||||
|
||||
if (savedRange) {
|
||||
try { setRange(JSON.parse(savedRange)); } catch { /* keep default */ }
|
||||
try {
|
||||
setRange(JSON.parse(savedRange));
|
||||
} catch {
|
||||
/* keep default */
|
||||
}
|
||||
} else {
|
||||
setRange({ start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) });
|
||||
}
|
||||
@@ -71,9 +79,14 @@ export function PlannerPage() {
|
||||
e.preventDefault();
|
||||
setPlannerLoading(true);
|
||||
const body = { startDate: toIsoString(range.start), endDate: toIsoString(range.end) };
|
||||
const rows = await fetch("/api/medications/usage", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })
|
||||
const rows = (await fetch("/api/medications/usage", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.catch(() => []) as PlannerRow[];
|
||||
.catch(() => [])) as PlannerRow[];
|
||||
setPlannerRows(rows);
|
||||
setPlannerLoading(false);
|
||||
// Save to user-specific localStorage
|
||||
@@ -101,6 +114,7 @@ export function PlannerPage() {
|
||||
const res = await fetch("/api/planner/send-email", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
email: settings.notificationEmail,
|
||||
from: range.start,
|
||||
@@ -124,43 +138,70 @@ export function PlannerPage() {
|
||||
<section className="grid">
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<h2>{t('planner.title')}</h2>
|
||||
<h2>{t("planner.title")}</h2>
|
||||
</div>
|
||||
<form className="planner" onSubmit={runPlanner}>
|
||||
<label>
|
||||
{t('planner.from')}
|
||||
<input type="datetime-local" step="60" value={range.start} onChange={(e) => setRange({ ...range, start: e.target.value })} />
|
||||
{t("planner.from")}
|
||||
<input
|
||||
type="datetime-local"
|
||||
step="60"
|
||||
value={range.start}
|
||||
onChange={(e) => setRange({ ...range, start: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t('planner.until')}
|
||||
<input type="datetime-local" step="60" value={range.end} onChange={(e) => setRange({ ...range, end: e.target.value })} />
|
||||
{t("planner.until")}
|
||||
<input
|
||||
type="datetime-local"
|
||||
step="60"
|
||||
value={range.end}
|
||||
onChange={(e) => setRange({ ...range, end: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<div className="planner-actions">
|
||||
<button type="button" className="ghost" onClick={resetRange}>{t('common.reset')}</button>
|
||||
<button type="submit" disabled={plannerLoading}>{plannerLoading ? t('planner.calculating') : t('planner.calculate')}</button>
|
||||
<button type="button" className="ghost" onClick={resetRange}>
|
||||
{t("common.reset")}
|
||||
</button>
|
||||
<button type="submit" disabled={plannerLoading}>
|
||||
{plannerLoading ? t("planner.calculating") : t("planner.calculate")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{plannerRows.length > 0 && (
|
||||
<>
|
||||
<div className="table">
|
||||
<div className="table-head">
|
||||
<span>{t('planner.table.medication')}</span>
|
||||
<span>{t('planner.table.usage')}</span>
|
||||
<span>{t('planner.table.blistersNeeded')}</span>
|
||||
<span>{t('planner.table.available')}</span>
|
||||
<span>{t('table.status')}</span>
|
||||
<span>{t("planner.table.medication")}</span>
|
||||
<span>{t("planner.table.usage")}</span>
|
||||
<span>{t("planner.table.blistersNeeded")}</span>
|
||||
<span>{t("planner.table.available")}</span>
|
||||
<span>{t("table.status")}</span>
|
||||
</div>
|
||||
{plannerRows.map((row) => {
|
||||
const med = meds.find(m => m.name === row.medicationName);
|
||||
const med = meds.find((m) => m.name === row.medicationName);
|
||||
return (
|
||||
<div key={row.medicationId} className="table-row clickable" onClick={() => med && openMedDetail(med)}>
|
||||
<span data-label={t('planner.table.medication')} className="cell-with-avatar"><MedicationAvatar name={row.medicationName} imageUrl={med?.imageUrl} />{row.medicationName}</span>
|
||||
<span data-label={t('planner.table.usage')}><strong>{row.plannerUsage}</strong> {t('common.pills')}</span>
|
||||
<span data-label={t('planner.table.blisters')}>{row.blistersNeeded} × {row.blisterSize}</span>
|
||||
<span data-label={t('planner.table.available')}>
|
||||
{row.fullBlisters} {t('common.blisters')}{row.loosePills > 0 && ` + ${row.loosePills} ${t('common.pills')}`}
|
||||
<span data-label={t("planner.table.medication")} className="cell-with-avatar">
|
||||
<MedicationAvatar name={row.medicationName} imageUrl={med?.imageUrl} />
|
||||
{row.medicationName}
|
||||
</span>
|
||||
<span data-label={t("planner.table.usage")}>
|
||||
<strong>{row.plannerUsage}</strong> {t("common.pills")}
|
||||
</span>
|
||||
<span data-label={t("planner.table.blisters")}>
|
||||
{row.blistersNeeded} × {row.blisterSize}
|
||||
</span>
|
||||
<span data-label={t("planner.table.available")}>
|
||||
{row.fullBlisters} {t("common.blisters")}
|
||||
{row.loosePills > 0 && ` + ${Math.round(row.loosePills * 10) / 10} ${t("common.pills")}`}
|
||||
</span>
|
||||
<span
|
||||
data-label={t("table.status")}
|
||||
className={row.enough ? "status-chip success" : "status-chip danger"}
|
||||
>
|
||||
{row.enough ? t("status.enough") : t("status.outOfStock")}
|
||||
</span>
|
||||
<span data-label={t('table.status')} className={row.enough ? "status-chip success" : "status-chip danger"}>{row.enough ? t('status.enough') : t('status.outOfStock')}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -168,7 +209,7 @@ export function PlannerPage() {
|
||||
{settings.emailEnabled && settings.notificationEmail && (
|
||||
<div className="planner-email-action">
|
||||
<button type="button" className="ghost" onClick={sendPlannerEmail} disabled={sendingPlannerEmail}>
|
||||
{sendingPlannerEmail ? t('common.sending') : t('planner.sendEmail')}
|
||||
{sendingPlannerEmail ? t("common.sending") : t("planner.sendEmail")}
|
||||
</button>
|
||||
{plannerEmailResult && (
|
||||
<span className={plannerEmailResult.success ? "success-text" : "danger-text"}>
|
||||
|
||||
+255
-135
@@ -1,7 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MedicationAvatar } from "../components";
|
||||
import { useAuth } from "../components/Auth";
|
||||
import { useAppContext } from "../context";
|
||||
import { MedicationAvatar } from "../components";
|
||||
import type { Coverage } from "../types";
|
||||
|
||||
// Helper for user-specific localStorage keys
|
||||
@@ -9,16 +9,32 @@ function userStorageKey(userId: number | undefined, key: string): string {
|
||||
return userId ? `user_${userId}_${key}` : key;
|
||||
}
|
||||
|
||||
// Helper function to get stock status
|
||||
function getStockStatus(daysLeft: number | null, medsLeft: number, settings: { lowStockDays: number; normalStockDays: number; highStockDays: number }) {
|
||||
if (medsLeft <= 0 || daysLeft === null || daysLeft <= 0) return { className: "danger", label: "status.outOfStock" };
|
||||
if (daysLeft <= settings.lowStockDays) return { className: "danger", label: "status.lowStock" };
|
||||
if (daysLeft >= settings.highStockDays) return { className: "success", label: "status.highStock" };
|
||||
// Helper function to get stock status based on thresholds
|
||||
function getStockStatus(
|
||||
daysLeft: number | null,
|
||||
medsLeft: number,
|
||||
settings: { lowStockDays: number; normalStockDays: number; highStockDays: number; reminderDaysBefore: number }
|
||||
) {
|
||||
// Out of stock or completely depleted = danger (red)
|
||||
if (medsLeft <= 0 || daysLeft === 0) return { className: "danger", label: "status.outOfStock" };
|
||||
// No schedule, but has stock = normal
|
||||
if (daysLeft === null) return { className: "success", label: "status.noSchedule" };
|
||||
// Critical: at or below reminder threshold = danger (red)
|
||||
if (daysLeft <= settings.reminderDaysBefore) return { className: "danger", label: "status.criticalStock" };
|
||||
// Low: below low stock threshold = warning (yellow)
|
||||
if (daysLeft < settings.lowStockDays) return { className: "warning", label: "status.lowStock" };
|
||||
// High stock
|
||||
if (daysLeft >= settings.highStockDays) return { className: "high", label: "status.highStock" };
|
||||
// Normal stock
|
||||
return { className: "success", label: "status.normal" };
|
||||
}
|
||||
|
||||
// Helper function to get worst stock status for a day
|
||||
function getDayStockStatus(dayMeds: Array<{ medName: string }>, coverageByMed: Record<string, Coverage>, settings: { lowStockDays: number; normalStockDays: number; highStockDays: number }): string {
|
||||
function getDayStockStatus(
|
||||
dayMeds: Array<{ medName: string }>,
|
||||
coverageByMed: Record<string, Coverage>,
|
||||
settings: { lowStockDays: number; normalStockDays: number; highStockDays: number; reminderDaysBefore: number }
|
||||
): string {
|
||||
let worstLevel = 3; // 3=success, 2=warning, 1=danger
|
||||
for (const item of dayMeds) {
|
||||
const cov = coverageByMed[item.medName];
|
||||
@@ -55,14 +71,15 @@ export function SchedulePage() {
|
||||
manuallyExpandedDays,
|
||||
toggleDayCollapse,
|
||||
openUserFilter,
|
||||
missedPastDoseIds,
|
||||
} = useAppContext();
|
||||
|
||||
return (
|
||||
<section className="grid">
|
||||
<article className="card schedule-full">
|
||||
<div className="card-head">
|
||||
<h2>{t('dashboard.schedules.title')}</h2>
|
||||
<select
|
||||
<h2>{t("dashboard.schedules.title")}</h2>
|
||||
<select
|
||||
className="schedule-days-select"
|
||||
value={scheduleDays}
|
||||
onChange={(e) => {
|
||||
@@ -71,89 +88,261 @@ export function SchedulePage() {
|
||||
if (user?.id) localStorage.setItem(userStorageKey(user.id, "scheduleDays"), String(val));
|
||||
}}
|
||||
>
|
||||
<option value={30}>{t('dashboard.schedules.1month')}</option>
|
||||
<option value={90}>{t('dashboard.schedules.3months')}</option>
|
||||
<option value={180}>{t('dashboard.schedules.6months')}</option>
|
||||
<option value={30}>{t("dashboard.schedules.1month")}</option>
|
||||
<option value={90}>{t("dashboard.schedules.3months")}</option>
|
||||
<option value={180}>{t("dashboard.schedules.6months")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="timeline">
|
||||
{/* Past days toggle */}
|
||||
{pastDays.length > 0 && (() => {
|
||||
const totalPastDoses = pastDays.flatMap(d => d.meds.flatMap(m => m.doses.flatMap(dose => (dose.takenBy || []).length > 0 ? dose.takenBy.map(p => `${dose.id}-${p}`) : [dose.id])));
|
||||
const missedPastDoses = totalPastDoses.filter(id => !takenDoses.has(id)).length;
|
||||
return (
|
||||
<div
|
||||
className={`past-days-toggle ${showPastDays ? 'expanded' : ''} ${missedPastDoses > 0 ? 'has-missed' : ''}`}
|
||||
onClick={() => setShowPastDays(!showPastDays)}
|
||||
>
|
||||
<span className="past-days-icon">{showPastDays ? '▼' : '▶'}</span>
|
||||
<span className="past-days-label">
|
||||
{showPastDays ? t('dashboard.schedules.hidePastDays') : t('dashboard.schedules.showPastDays')}
|
||||
</span>
|
||||
<span className="past-days-count">({t('dashboard.schedules.pastDaysCount', { count: pastDays.length })})</span>
|
||||
{missedPastDoses > 0 && <span className="past-days-warning" title={t('dashboard.schedules.missedDoses', { count: missedPastDoses })}>⚠️ {missedPastDoses}</span>}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{/* Past days (when expanded) */}
|
||||
{showPastDays && pastDays.map((day) => {
|
||||
const allDoseIds = day.meds.flatMap((item) => item.doses.flatMap((d) => (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]));
|
||||
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
|
||||
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
|
||||
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
|
||||
const isCollapsed = !isManuallyExpanded;
|
||||
const worstStatus = getDayStockStatus(day.meds, coverageByMed, settings);
|
||||
|
||||
return (
|
||||
<div key={day.dateStr} className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} stock-${worstStatus}`}>
|
||||
<div
|
||||
className="day-divider clickable"
|
||||
onClick={() => toggleDayCollapse(day.dateStr, true)}
|
||||
title={isCollapsed ? t('common.expand') : t('common.collapse')}
|
||||
{pastDays.length > 0 &&
|
||||
(() => {
|
||||
// Use context's missedPastDoseIds which handles dismissed doses and previous schedule detection
|
||||
const missedCount = missedPastDoseIds.length;
|
||||
return (
|
||||
<div
|
||||
className={`past-days-toggle ${showPastDays ? "expanded" : ""} ${missedCount > 0 ? "has-missed" : ""}`}
|
||||
onClick={() => setShowPastDays(!showPastDays)}
|
||||
>
|
||||
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
|
||||
<span className="day-date">{day.dateStr}</span>
|
||||
<span className="day-summary">
|
||||
{allDayTaken ? (
|
||||
<span className="day-complete">✓ {t('dashboard.schedules.allTaken')}</span>
|
||||
) : (
|
||||
<><span className="day-warning" title={t('dashboard.schedules.missedDoses', { count: allDoseIds.length - takenCount })}>⚠️</span><span className="day-progress">{takenCount}/{allDoseIds.length}</span></>
|
||||
)}
|
||||
<span className="past-days-icon">{showPastDays ? "▼" : "▶"}</span>
|
||||
<span className="past-days-label">
|
||||
{showPastDays ? t("dashboard.schedules.hidePastDays") : t("dashboard.schedules.showPastDays")}
|
||||
</span>
|
||||
<span className="past-days-count">
|
||||
({t("dashboard.schedules.pastDaysCount", { count: pastDays.length })})
|
||||
</span>
|
||||
{missedCount > 0 && (
|
||||
<span
|
||||
className="past-days-warning"
|
||||
title={t("dashboard.schedules.missedDoses", { count: missedCount })}
|
||||
>
|
||||
⚠️ {missedCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!isCollapsed && day.meds.map((item) => {
|
||||
const med = meds.find(m => m.name === item.medName);
|
||||
const medCov = coverageByMed[item.medName];
|
||||
const isEmpty = medCov ? medCov.medsLeft <= 0 : false;
|
||||
const itemDoseIds = item.doses.flatMap((d) => (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]);
|
||||
);
|
||||
})()}
|
||||
{/* Past days (when expanded) */}
|
||||
{showPastDays &&
|
||||
pastDays.map((day) => {
|
||||
const allDoseIds = day.meds.flatMap((item) =>
|
||||
item.doses.flatMap((d) =>
|
||||
(d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]
|
||||
)
|
||||
);
|
||||
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
|
||||
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
|
||||
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
|
||||
const isCollapsed = !isManuallyExpanded;
|
||||
const worstStatus = getDayStockStatus(day.meds, coverageByMed, settings);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day.dateStr}
|
||||
className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} stock-${worstStatus}`}
|
||||
>
|
||||
<div
|
||||
className="day-divider clickable"
|
||||
onClick={() => toggleDayCollapse(day.dateStr, true)}
|
||||
title={isCollapsed ? t("common.expand") : t("common.collapse")}
|
||||
>
|
||||
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
|
||||
<span className="day-date">{day.dateStr}</span>
|
||||
<span className="day-summary">
|
||||
{allDayTaken ? (
|
||||
<span className="day-complete">✓ {t("dashboard.schedules.allTaken")}</span>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className="day-warning"
|
||||
title={t("dashboard.schedules.missedDoses", { count: allDoseIds.length - takenCount })}
|
||||
>
|
||||
⚠️
|
||||
</span>
|
||||
<span className="day-progress">
|
||||
{takenCount}/{allDoseIds.length}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{!isCollapsed &&
|
||||
day.meds.map((item) => {
|
||||
const med = meds.find((m) => m.name === item.medName);
|
||||
const medCov = coverageByMed[item.medName];
|
||||
const isEmpty = medCov ? medCov.medsLeft <= 0 : false;
|
||||
const itemDoseIds = item.doses.flatMap((d) =>
|
||||
(d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]
|
||||
);
|
||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||
return (
|
||||
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
|
||||
<div className="time-main">
|
||||
<div className="med-name">
|
||||
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
||||
<span className="med-name-text">{item.medName}</span>
|
||||
{med?.intakeRemindersEnabled && (
|
||||
<span
|
||||
className="reminder-icon info-tooltip"
|
||||
data-tooltip={t("tooltips.intakeReminders")}
|
||||
>
|
||||
🔔
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="tag-row">
|
||||
<span className="tag subtle">
|
||||
{item.total} {t("common.pills")} {t("common.total")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="doses-col">
|
||||
{item.doses.map((dose) => {
|
||||
// If no takenBy, show single checkbox; otherwise show one per person
|
||||
const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null];
|
||||
return (
|
||||
<div key={dose.id} className="dose-item past">
|
||||
<span className="dose-time">{dose.timeStr}</span>
|
||||
<span className="dose-usage">
|
||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
||||
{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
|
||||
</span>
|
||||
<div className="dose-checks">
|
||||
{people.map((person) => {
|
||||
const doseId = getDoseId(dose.id, person);
|
||||
const isTaken = takenDoses.has(doseId);
|
||||
return (
|
||||
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||
{person && (
|
||||
<span
|
||||
className="person-name clickable"
|
||||
onClick={() => openUserFilter(person)}
|
||||
>
|
||||
{person}
|
||||
</span>
|
||||
)}
|
||||
{isTaken ? (
|
||||
<button
|
||||
className="dose-btn undo"
|
||||
onClick={() => undoDoseTaken(doseId)}
|
||||
title={t("common.undo")}
|
||||
>
|
||||
↩
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="dose-btn take"
|
||||
onClick={() => markDoseTaken(doseId)}
|
||||
disabled={isEmpty}
|
||||
title={t("dose.markAsTaken")}
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Current and future days */}
|
||||
{futureDays.map((day) => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const dayDate = new Date(day.date);
|
||||
dayDate.setHours(0, 0, 0, 0);
|
||||
const isToday = dayDate.getTime() === today.getTime();
|
||||
return (
|
||||
<div key={day.dateStr} className={`day-block ${isToday ? "today" : ""}`}>
|
||||
<div className="day-divider">{day.dateStr}</div>
|
||||
{day.meds.map((item) => {
|
||||
const medCoverage = coverageByMed[item.medName];
|
||||
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
|
||||
const med = meds.find((m) => m.name === item.medName);
|
||||
const depletionTime = depletionByMed[item.medName];
|
||||
// Check if this dose is scheduled after medication runs out
|
||||
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
|
||||
const status = willBeOutOfStock
|
||||
? { className: "danger", label: "status.outOfStock" }
|
||||
: medCoverage
|
||||
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings)
|
||||
: null;
|
||||
const itemDoseIds = item.doses.flatMap((d) =>
|
||||
(d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]
|
||||
);
|
||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||
return (
|
||||
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
|
||||
<div className="time-main">
|
||||
<div className="med-name"><MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" /><span className="med-name-text">{item.medName}</span>{med?.intakeRemindersEnabled && <span className="reminder-icon info-tooltip" data-tooltip={t('tooltips.intakeReminders')}>🔔</span>}</div>
|
||||
<div className="med-name">
|
||||
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
||||
<span className="med-name-text">{item.medName}</span>
|
||||
{med?.intakeRemindersEnabled && (
|
||||
<span className="reminder-icon info-tooltip" data-tooltip={t("tooltips.intakeReminders")}>
|
||||
🔔
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="tag-row">
|
||||
<span className="tag subtle">{item.total} {t('common.pills')} {t('common.total')}</span>
|
||||
<span className="tag subtle">
|
||||
{item.total} {t("common.pills")} {t("common.total")}
|
||||
</span>
|
||||
{status && <span className={`tag ${status.className}`}>{t(status.label)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="doses-col">
|
||||
{item.doses.map((dose) => {
|
||||
// If no takenBy, show single checkbox; otherwise show one per person
|
||||
const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null];
|
||||
const now = Date.now();
|
||||
const dayStart = new Date(day.date).setHours(0, 0, 0, 0);
|
||||
const isPastDay = dayStart < new Date().setHours(0, 0, 0, 0);
|
||||
return (
|
||||
<div key={dose.id} className="dose-item past">
|
||||
<div key={dose.id} className="dose-item">
|
||||
<span className="dose-time">{dose.timeStr}</span>
|
||||
<span className="dose-usage">{dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}</span>
|
||||
<span className="dose-usage">
|
||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
||||
{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
|
||||
</span>
|
||||
<div className="dose-checks">
|
||||
{people.map((person) => {
|
||||
const doseId = getDoseId(dose.id, person);
|
||||
const isTaken = takenDoses.has(doseId);
|
||||
const isOverdue = !isTaken && dose.when < now && !isPastDay;
|
||||
return (
|
||||
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||
{person && <span className="person-name clickable" onClick={() => openUserFilter(person)}>{person}</span>}
|
||||
<div
|
||||
key={doseId}
|
||||
className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}
|
||||
>
|
||||
{person && (
|
||||
<span className="person-name clickable" onClick={() => openUserFilter(person)}>
|
||||
{person}
|
||||
</span>
|
||||
)}
|
||||
{isTaken ? (
|
||||
<button className="dose-btn undo" onClick={() => undoDoseTaken(doseId)} title={t('common.undo')}>↩</button>
|
||||
<button
|
||||
className="dose-btn undo"
|
||||
onClick={() => undoDoseTaken(doseId)}
|
||||
title={t("common.undo")}
|
||||
>
|
||||
↩
|
||||
</button>
|
||||
) : (
|
||||
<button className="dose-btn take" onClick={() => markDoseTaken(doseId)} disabled={isEmpty} title={t('dose.markAsTaken')}>✓</button>
|
||||
<button
|
||||
className="dose-btn take"
|
||||
onClick={() => markDoseTaken(doseId)}
|
||||
disabled={isEmpty}
|
||||
title={t("dose.markAsTaken")}
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -169,75 +358,6 @@ export function SchedulePage() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Current and future days */}
|
||||
{futureDays.map((day) => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const dayDate = new Date(day.date);
|
||||
dayDate.setHours(0, 0, 0, 0);
|
||||
const isToday = dayDate.getTime() === today.getTime();
|
||||
return (
|
||||
<div key={day.dateStr} className={`day-block ${isToday ? "today" : ""}`}>
|
||||
<div className="day-divider">{day.dateStr}</div>
|
||||
{day.meds.map((item) => {
|
||||
const medCoverage = coverageByMed[item.medName];
|
||||
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
|
||||
const med = meds.find(m => m.name === item.medName);
|
||||
const depletionTime = depletionByMed[item.medName];
|
||||
// Check if this dose is scheduled after medication runs out
|
||||
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
|
||||
const status = willBeOutOfStock
|
||||
? { className: "danger", label: "status.outOfStock" }
|
||||
: medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null;
|
||||
const itemDoseIds = item.doses.flatMap((d) => (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]);
|
||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||
return (
|
||||
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
|
||||
<div className="time-main">
|
||||
<div className="med-name"><MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" /><span className="med-name-text">{item.medName}</span>{med?.intakeRemindersEnabled && <span className="reminder-icon info-tooltip" data-tooltip={t('tooltips.intakeReminders')}>🔔</span>}</div>
|
||||
<div className="tag-row">
|
||||
<span className="tag subtle">{item.total} {t('common.pills')} {t('common.total')}</span>
|
||||
{status && <span className={`tag ${status.className}`}>
|
||||
{t(status.label)}
|
||||
</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="doses-col">
|
||||
{item.doses.map((dose) => {
|
||||
const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null];
|
||||
const now = Date.now();
|
||||
const dayStart = new Date(day.date).setHours(0, 0, 0, 0);
|
||||
const isPastDay = dayStart < new Date().setHours(0, 0, 0, 0);
|
||||
return (
|
||||
<div key={dose.id} className="dose-item">
|
||||
<span className="dose-time">{dose.timeStr}</span>
|
||||
<span className="dose-usage">{dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}</span>
|
||||
<div className="dose-checks">
|
||||
{people.map((person) => {
|
||||
const doseId = getDoseId(dose.id, person);
|
||||
const isTaken = takenDoses.has(doseId);
|
||||
const isOverdue = !isTaken && dose.when < now && !isPastDay;
|
||||
return (
|
||||
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}>
|
||||
{person && <span className="person-name clickable" onClick={() => openUserFilter(person)}>{person}</span>}
|
||||
{isTaken ? (
|
||||
<button className="dose-btn undo" onClick={() => undoDoseTaken(doseId)} title={t('common.undo')}>↩</button>
|
||||
) : (
|
||||
<button className="dose-btn take" onClick={() => markDoseTaken(doseId)} disabled={isEmpty} title={t('dose.markAsTaken')}>✓</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);})}
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
+249
-118
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppContext } from "../context";
|
||||
import { ConfirmModal, ExportModal } from "../components";
|
||||
import { useAppContext } from "../context";
|
||||
import { getSystemLocale } from "../utils/formatters";
|
||||
|
||||
export function SettingsPage() {
|
||||
@@ -30,7 +30,6 @@ export function SettingsPage() {
|
||||
handleImportFileSelect,
|
||||
showImportConfirm,
|
||||
setShowImportConfirm,
|
||||
pendingImportData,
|
||||
setPendingImportData,
|
||||
handleImportConfirm,
|
||||
importResult,
|
||||
@@ -40,17 +39,17 @@ export function SettingsPage() {
|
||||
return (
|
||||
<section className="grid">
|
||||
{settingsLoading ? (
|
||||
<p>{t('settings.loading')}</p>
|
||||
<p>{t("settings.loading")}</p>
|
||||
) : (
|
||||
<form className="settings-form" onSubmit={saveSettings}>
|
||||
{/* Language */}
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<h2>{t('settings.language.title')}</h2>
|
||||
<h2>{t("settings.language.title")}</h2>
|
||||
</div>
|
||||
<div className="setting-section">
|
||||
<label className="setting-row language-row">
|
||||
<span className="setting-label">{t('settings.language.select')}</span>
|
||||
<span className="setting-label">{t("settings.language.select")}</span>
|
||||
<select
|
||||
value={i18n.language}
|
||||
onChange={(e) => i18n.changeLanguage(e.target.value)}
|
||||
@@ -66,23 +65,23 @@ export function SettingsPage() {
|
||||
{/* Notifications */}
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<h2>{t('settings.notifications.title')}</h2>
|
||||
<h2>{t("settings.notifications.title")}</h2>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="setting-section">
|
||||
<div className="section-header">
|
||||
<h3>{t('settings.notifications.channels')}</h3>
|
||||
<h3>{t("settings.notifications.channels")}</h3>
|
||||
</div>
|
||||
<div className="notification-matrix">
|
||||
<div className="matrix-header">
|
||||
<div className="matrix-label"></div>
|
||||
<div className="matrix-channel">{t('settings.notifications.email')}</div>
|
||||
<div className="matrix-channel">{t('settings.notifications.push')}</div>
|
||||
<div className="matrix-channel">{t("settings.notifications.email")}</div>
|
||||
<div className="matrix-channel">{t("settings.notifications.push")}</div>
|
||||
</div>
|
||||
<div className="matrix-row">
|
||||
<div className="matrix-label">{t('settings.notifications.stockReminders')}</div>
|
||||
<div className="matrix-label">{t("settings.notifications.stockReminders")}</div>
|
||||
<div className="matrix-cell">
|
||||
<label className={`toggle-switch small${!settings.emailEnabled ? ' disabled' : ''}`}>
|
||||
<label className={`toggle-switch small${!settings.emailEnabled ? " disabled" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.smtpHost && settings.emailEnabled ? settings.emailStockReminders : false}
|
||||
@@ -93,10 +92,12 @@ export function SettingsPage() {
|
||||
</label>
|
||||
</div>
|
||||
<div className="matrix-cell">
|
||||
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? ' disabled' : ''}`}>
|
||||
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? " disabled" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.shoutrrrUrl && settings.shoutrrrEnabled ? settings.shoutrrrStockReminders : false}
|
||||
checked={
|
||||
settings.shoutrrrUrl && settings.shoutrrrEnabled ? settings.shoutrrrStockReminders : false
|
||||
}
|
||||
onChange={(e) => setSettings({ ...settings, shoutrrrStockReminders: e.target.checked })}
|
||||
disabled={!settings.shoutrrrEnabled}
|
||||
/>
|
||||
@@ -105,9 +106,9 @@ export function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="matrix-row">
|
||||
<div className="matrix-label">{t('settings.notifications.intakeReminders')}</div>
|
||||
<div className="matrix-label">{t("settings.notifications.intakeReminders")}</div>
|
||||
<div className="matrix-cell">
|
||||
<label className={`toggle-switch small${!settings.emailEnabled ? ' disabled' : ''}`}>
|
||||
<label className={`toggle-switch small${!settings.emailEnabled ? " disabled" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.smtpHost && settings.emailEnabled ? settings.emailIntakeReminders : false}
|
||||
@@ -118,10 +119,12 @@ export function SettingsPage() {
|
||||
</label>
|
||||
</div>
|
||||
<div className="matrix-cell">
|
||||
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? ' disabled' : ''}`}>
|
||||
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? " disabled" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.shoutrrrUrl && settings.shoutrrrEnabled ? settings.shoutrrrIntakeReminders : false}
|
||||
checked={
|
||||
settings.shoutrrrUrl && settings.shoutrrrEnabled ? settings.shoutrrrIntakeReminders : false
|
||||
}
|
||||
onChange={(e) => setSettings({ ...settings, shoutrrrIntakeReminders: e.target.checked })}
|
||||
disabled={!settings.shoutrrrEnabled}
|
||||
/>
|
||||
@@ -131,16 +134,20 @@ export function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
{!settings.emailEnabled && !settings.shoutrrrEnabled && (
|
||||
<p className="hint-text">{t('settings.notifications.enableHint')}</p>
|
||||
<p className="hint-text">{t("settings.notifications.enableHint")}</p>
|
||||
)}
|
||||
|
||||
|
||||
{/* Skip reminders for taken doses */}
|
||||
<div className="setting-row compact" style={{marginTop: "16px"}}>
|
||||
<div className="setting-row compact" style={{ marginTop: "16px" }}>
|
||||
<label className="setting-label">
|
||||
{t('settings.notifications.skipTakenDoses')}
|
||||
<span className="info-tooltip small" data-tooltip={t('settings.notifications.skipTakenDosesTooltip')}>ⓘ</span>
|
||||
{t("settings.notifications.skipTakenDoses")}
|
||||
<span className="info-tooltip small" data-tooltip={t("settings.notifications.skipTakenDosesTooltip")}>
|
||||
ⓘ
|
||||
</span>
|
||||
</label>
|
||||
<label className={`toggle-switch small${!settings.emailEnabled && !settings.shoutrrrEnabled ? ' disabled' : ''}`}>
|
||||
<label
|
||||
className={`toggle-switch small${!settings.emailEnabled && !settings.shoutrrrEnabled ? " disabled" : ""}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.skipRemindersForTakenDoses}
|
||||
@@ -150,14 +157,21 @@ export function SettingsPage() {
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Repeat reminders for missed doses */}
|
||||
<div className="setting-row compact" style={{marginTop: "12px"}}>
|
||||
<div className="setting-row compact" style={{ marginTop: "12px" }}>
|
||||
<label className="setting-label">
|
||||
{t('settings.notifications.repeatReminders')}
|
||||
<span className="info-tooltip small" data-tooltip={t('settings.notifications.repeatRemindersTooltip')}>ⓘ</span>
|
||||
{t("settings.notifications.repeatReminders")}
|
||||
<span
|
||||
className="info-tooltip small"
|
||||
data-tooltip={t("settings.notifications.repeatRemindersTooltip")}
|
||||
>
|
||||
ⓘ
|
||||
</span>
|
||||
</label>
|
||||
<label className={`toggle-switch small${!settings.emailEnabled && !settings.shoutrrrEnabled ? ' disabled' : ''}`}>
|
||||
<label
|
||||
className={`toggle-switch small${!settings.emailEnabled && !settings.shoutrrrEnabled ? " disabled" : ""}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.repeatRemindersEnabled}
|
||||
@@ -167,14 +181,19 @@ export function SettingsPage() {
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Reminder interval (only shown when repeat is enabled) */}
|
||||
{settings.repeatRemindersEnabled && (
|
||||
<>
|
||||
<div className="setting-row compact" style={{marginTop: "12px", marginLeft: "24px"}}>
|
||||
<div className="setting-row compact" style={{ marginTop: "12px", marginLeft: "24px" }}>
|
||||
<label className="setting-label">
|
||||
{t('settings.notifications.reminderInterval')}
|
||||
<span className="info-tooltip small" data-tooltip={t('settings.notifications.reminderIntervalTooltip')}>ⓘ</span>
|
||||
{t("settings.notifications.reminderInterval")}
|
||||
<span
|
||||
className="info-tooltip small"
|
||||
data-tooltip={t("settings.notifications.reminderIntervalTooltip")}
|
||||
>
|
||||
ⓘ
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -182,14 +201,21 @@ export function SettingsPage() {
|
||||
max="480"
|
||||
step="5"
|
||||
value={settings.reminderRepeatIntervalMinutes}
|
||||
onChange={(e) => setSettings({ ...settings, reminderRepeatIntervalMinutes: parseInt(e.target.value) || 30 })}
|
||||
style={{width: "80px", textAlign: "center"}}
|
||||
onChange={(e) =>
|
||||
setSettings({ ...settings, reminderRepeatIntervalMinutes: parseInt(e.target.value, 10) || 30 })
|
||||
}
|
||||
style={{ width: "80px", textAlign: "center" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="setting-row compact" style={{marginTop: "8px", marginLeft: "24px"}}>
|
||||
<div className="setting-row compact" style={{ marginTop: "8px", marginLeft: "24px" }}>
|
||||
<label className="setting-label">
|
||||
{t('settings.notifications.maxNaggingReminders')}
|
||||
<span className="info-tooltip small" data-tooltip={t('settings.notifications.maxNaggingRemindersTooltip')}>ⓘ</span>
|
||||
{t("settings.notifications.maxNaggingReminders")}
|
||||
<span
|
||||
className="info-tooltip small"
|
||||
data-tooltip={t("settings.notifications.maxNaggingRemindersTooltip")}
|
||||
>
|
||||
ⓘ
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -197,8 +223,13 @@ export function SettingsPage() {
|
||||
max="20"
|
||||
step="1"
|
||||
value={settings.maxNaggingReminders ?? 5}
|
||||
onChange={(e) => setSettings({ ...settings, maxNaggingReminders: parseInt(e.target.value) || 5 })}
|
||||
style={{width: "80px", textAlign: "center"}}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value, 10);
|
||||
if (!Number.isNaN(val)) {
|
||||
setSettings({ ...settings, maxNaggingReminders: Math.max(1, Math.min(20, val)) });
|
||||
}
|
||||
}}
|
||||
style={{ width: "80px", textAlign: "center" }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -207,15 +238,22 @@ export function SettingsPage() {
|
||||
|
||||
<div className="setting-section">
|
||||
<div className="section-header">
|
||||
<h3>{t('settings.notifications.email')}</h3>
|
||||
<label className={`toggle-switch small${!settings.smtpHost ? ' disabled' : ''}`}>
|
||||
<h3>{t("settings.notifications.email")}</h3>
|
||||
<label className={`toggle-switch small${!settings.smtpHost ? " disabled" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.smtpHost ? settings.emailEnabled : false}
|
||||
onChange={(e) => {
|
||||
const newVal = e.target.checked;
|
||||
if (!newVal && !settings.shoutrrrEnabled) {
|
||||
setSettings({ ...settings, emailEnabled: false, emailStockReminders: false, emailIntakeReminders: false, skipRemindersForTakenDoses: false, repeatRemindersEnabled: false });
|
||||
setSettings({
|
||||
...settings,
|
||||
emailEnabled: false,
|
||||
emailStockReminders: false,
|
||||
emailIntakeReminders: false,
|
||||
skipRemindersForTakenDoses: false,
|
||||
repeatRemindersEnabled: false,
|
||||
});
|
||||
} else {
|
||||
setSettings({ ...settings, emailEnabled: newVal });
|
||||
}
|
||||
@@ -229,7 +267,7 @@ export function SettingsPage() {
|
||||
<>
|
||||
<div className="setting-group">
|
||||
<label className="full">
|
||||
<span className="field-label">{t('settings.email.recipient')}</span>
|
||||
<span className="field-label">{t("settings.email.recipient")}</span>
|
||||
<div className="input-with-tooltip">
|
||||
<input
|
||||
type="email"
|
||||
@@ -239,13 +277,23 @@ export function SettingsPage() {
|
||||
pattern="[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$"
|
||||
autoComplete="email"
|
||||
/>
|
||||
<span className="info-tooltip" data-tooltip={`SMTP: ${settings.smtpHost || t('settings.email.notConfigured')}:${settings.smtpPort}${settings.hasSmtpPassword ? '\nPassword: ✓' : ''}`}>ⓘ</span>
|
||||
<span
|
||||
className="info-tooltip"
|
||||
data-tooltip={`SMTP: ${settings.smtpHost || t("settings.email.notConfigured")}:${settings.smtpPort}${settings.hasSmtpPassword ? "\nPassword: ✓" : ""}`}
|
||||
>
|
||||
ⓘ
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="setting-actions">
|
||||
<button type="button" className="ghost" onClick={testEmail} disabled={testingEmail || !settings.notificationEmail}>
|
||||
{testingEmail ? t('common.sending') : t('common.test')}
|
||||
<button
|
||||
type="button"
|
||||
className="ghost"
|
||||
onClick={testEmail}
|
||||
disabled={testingEmail || !settings.notificationEmail}
|
||||
>
|
||||
{testingEmail ? t("common.sending") : t("common.test")}
|
||||
</button>
|
||||
{testEmailResult && (
|
||||
<span className={testEmailResult.success ? "success-text" : "danger-text"}>
|
||||
@@ -259,7 +307,7 @@ export function SettingsPage() {
|
||||
|
||||
<div className="setting-section">
|
||||
<div className="section-header">
|
||||
<h3>{t('settings.notifications.push')}</h3>
|
||||
<h3>{t("settings.notifications.push")}</h3>
|
||||
<label className="toggle-switch small">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -267,7 +315,14 @@ export function SettingsPage() {
|
||||
onChange={(e) => {
|
||||
const newVal = e.target.checked;
|
||||
if (!newVal && !settings.emailEnabled) {
|
||||
setSettings({ ...settings, shoutrrrEnabled: false, shoutrrrStockReminders: false, shoutrrrIntakeReminders: false, skipRemindersForTakenDoses: false, repeatRemindersEnabled: false });
|
||||
setSettings({
|
||||
...settings,
|
||||
shoutrrrEnabled: false,
|
||||
shoutrrrStockReminders: false,
|
||||
shoutrrrIntakeReminders: false,
|
||||
skipRemindersForTakenDoses: false,
|
||||
repeatRemindersEnabled: false,
|
||||
});
|
||||
} else {
|
||||
setSettings({ ...settings, shoutrrrEnabled: newVal });
|
||||
}
|
||||
@@ -280,21 +335,31 @@ export function SettingsPage() {
|
||||
<>
|
||||
<div className="setting-group">
|
||||
<label className="full">
|
||||
<span className="field-label">{t('settings.push.url')}</span>
|
||||
<span className="field-label">{t("settings.push.url")}</span>
|
||||
<div className="input-with-tooltip">
|
||||
<input
|
||||
type="text"
|
||||
value={settings.shoutrrrUrl}
|
||||
onChange={(e) => setSettings({ ...settings, shoutrrrUrl: e.target.value })}
|
||||
placeholder={t('settings.push.urlPlaceholder')}
|
||||
placeholder={t("settings.push.urlPlaceholder")}
|
||||
/>
|
||||
<span className="info-tooltip" data-tooltip={`${t('settings.push.supports')}\n\n${t('settings.push.docsLink')}`}>ⓘ</span>
|
||||
<span
|
||||
className="info-tooltip"
|
||||
data-tooltip={`${t("settings.push.supports")}\n\n${t("settings.push.docsLink")}`}
|
||||
>
|
||||
ⓘ
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="setting-actions">
|
||||
<button type="button" className="ghost" onClick={testShoutrrr} disabled={testingShoutrrr || !settings.shoutrrrUrl}>
|
||||
{testingShoutrrr ? t('common.sending') : t('common.test')}
|
||||
<button
|
||||
type="button"
|
||||
className="ghost"
|
||||
onClick={testShoutrrr}
|
||||
disabled={testingShoutrrr || !settings.shoutrrrUrl}
|
||||
>
|
||||
{testingShoutrrr ? t("common.sending") : t("common.test")}
|
||||
</button>
|
||||
{testShoutrrrResult && (
|
||||
<span className={testShoutrrrResult.success ? "success-text" : "danger-text"}>
|
||||
@@ -308,27 +373,45 @@ export function SettingsPage() {
|
||||
|
||||
<div className="schedule-overview">
|
||||
<div className="schedule-header">
|
||||
<span className="schedule-title">{t('settings.schedule.title')}</span>
|
||||
<span className="info-tooltip" data-tooltip={t('settings.schedule.envHint')}>ⓘ</span>
|
||||
<span className="schedule-title">{t("settings.schedule.title")}</span>
|
||||
<span className="info-tooltip" data-tooltip={t("settings.schedule.envHint")}>
|
||||
ⓘ
|
||||
</span>
|
||||
</div>
|
||||
<div className="schedule-row">
|
||||
<span className="schedule-label">{t('settings.schedule.stockCheck')}</span>
|
||||
<span className="schedule-value">{t('settings.schedule.dailyAt6')}</span>
|
||||
<span className="schedule-label">{t("settings.schedule.stockCheck")}</span>
|
||||
<span className="schedule-value">{t("settings.schedule.dailyAt6")}</span>
|
||||
</div>
|
||||
<div className="schedule-row">
|
||||
<span className="schedule-label">{t('settings.schedule.intakeCheck')}</span>
|
||||
<span className="schedule-value">{t('settings.schedule.15minBefore')}</span>
|
||||
<span className="schedule-label">{t("settings.schedule.intakeCheck")}</span>
|
||||
<span className="schedule-value">{t("settings.schedule.15minBefore")}</span>
|
||||
</div>
|
||||
{settings.nextScheduledCheck && (
|
||||
<div className="schedule-row">
|
||||
<span className="schedule-label">{t('settings.schedule.nextCheck')}</span>
|
||||
<span className="schedule-value">{new Date(settings.nextScheduledCheck).toLocaleString(getSystemLocale(i18n.language), { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" })}</span>
|
||||
<span className="schedule-label">{t("settings.schedule.nextCheck")}</span>
|
||||
<span className="schedule-value">
|
||||
{new Date(settings.nextScheduledCheck).toLocaleString(getSystemLocale(i18n.language), {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{settings.lastAutoEmailSent && (
|
||||
<div className="schedule-row">
|
||||
<span className="schedule-label">{t('settings.schedule.lastSent')}</span>
|
||||
<span className="schedule-value">{new Date(settings.lastAutoEmailSent).toLocaleString(getSystemLocale(i18n.language), { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" })}</span>
|
||||
<span className="schedule-label">{t("settings.schedule.lastSent")}</span>
|
||||
<span className="schedule-value">
|
||||
{new Date(settings.lastAutoEmailSent).toLocaleString(getSystemLocale(i18n.language), {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -337,16 +420,16 @@ export function SettingsPage() {
|
||||
{/* Stock Settings */}
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<h2>{t('settings.stock.title')}</h2>
|
||||
<h2>{t("settings.stock.title")}</h2>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="setting-section">
|
||||
<div className="section-header">
|
||||
<h3>{t('settings.stock.threshold')}</h3>
|
||||
<h3>{t("settings.stock.threshold")}</h3>
|
||||
</div>
|
||||
<div className="threshold-input">
|
||||
<label>
|
||||
<span className="threshold-label">{t('settings.stock.remindWhen')}</span>
|
||||
<span className="threshold-label">{t("settings.stock.remindWhen")}</span>
|
||||
<div className="threshold-field">
|
||||
<input
|
||||
type="number"
|
||||
@@ -355,21 +438,30 @@ export function SettingsPage() {
|
||||
value={settings.reminderDaysBefore}
|
||||
onChange={(e) => setSettings({ ...settings, reminderDaysBefore: Number(e.target.value) || 7 })}
|
||||
/>
|
||||
<span className="threshold-unit">{t('common.days')}</span>
|
||||
<span className="threshold-unit">{t("common.days")}</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="setting-row compact">
|
||||
<label className="setting-label">
|
||||
{t('settings.stock.repeatDaily')}
|
||||
<span className="info-tooltip small" data-tooltip={t('settings.stock.repeatTooltip')}>ⓘ</span>
|
||||
{t("settings.stock.repeatDaily")}
|
||||
<span className="info-tooltip small" data-tooltip={t("settings.stock.repeatTooltip")}>
|
||||
ⓘ
|
||||
</span>
|
||||
</label>
|
||||
<label className={`toggle-switch small${!((settings.emailEnabled && settings.emailStockReminders && settings.notificationEmail) || (settings.shoutrrrEnabled && settings.shoutrrrStockReminders && settings.shoutrrrUrl)) ? ' disabled' : ''}`}>
|
||||
<label
|
||||
className={`toggle-switch small${!((settings.emailEnabled && settings.emailStockReminders && settings.notificationEmail) || (settings.shoutrrrEnabled && settings.shoutrrrStockReminders && settings.shoutrrrUrl)) ? " disabled" : ""}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.repeatDailyReminders}
|
||||
onChange={(e) => setSettings({ ...settings, repeatDailyReminders: e.target.checked })}
|
||||
disabled={!((settings.emailEnabled && settings.emailStockReminders && settings.notificationEmail) || (settings.shoutrrrEnabled && settings.shoutrrrStockReminders && settings.shoutrrrUrl))}
|
||||
disabled={
|
||||
!(
|
||||
(settings.emailEnabled && settings.emailStockReminders && settings.notificationEmail) ||
|
||||
(settings.shoutrrrEnabled && settings.shoutrrrStockReminders && settings.shoutrrrUrl)
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
@@ -378,36 +470,40 @@ export function SettingsPage() {
|
||||
|
||||
<div className="setting-section">
|
||||
<div className="section-header">
|
||||
<h3>{t('settings.stock.calculationMode')}</h3>
|
||||
<h3>{t("settings.stock.calculationMode")}</h3>
|
||||
</div>
|
||||
<div className="setting-group calculation-mode-group">
|
||||
<label className={`radio-card ${settings.stockCalculationMode === 'automatic' ? 'selected' : ''}`}>
|
||||
<label className={`radio-card ${settings.stockCalculationMode === "automatic" ? "selected" : ""}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="stockCalculationMode"
|
||||
value="automatic"
|
||||
checked={settings.stockCalculationMode === 'automatic'}
|
||||
onChange={(e) => setSettings({ ...settings, stockCalculationMode: e.target.value as 'automatic' | 'manual' })}
|
||||
checked={settings.stockCalculationMode === "automatic"}
|
||||
onChange={(e) =>
|
||||
setSettings({ ...settings, stockCalculationMode: e.target.value as "automatic" | "manual" })
|
||||
}
|
||||
/>
|
||||
<div className="radio-card-content">
|
||||
<div className="radio-card-text">
|
||||
<span className="radio-card-title">{t('settings.stock.automatic')}</span>
|
||||
<span className="radio-card-desc">{t('settings.stock.automaticDesc')}</span>
|
||||
<span className="radio-card-title">{t("settings.stock.automatic")}</span>
|
||||
<span className="radio-card-desc">{t("settings.stock.automaticDesc")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<label className={`radio-card ${settings.stockCalculationMode === 'manual' ? 'selected' : ''}`}>
|
||||
<label className={`radio-card ${settings.stockCalculationMode === "manual" ? "selected" : ""}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="stockCalculationMode"
|
||||
value="manual"
|
||||
checked={settings.stockCalculationMode === 'manual'}
|
||||
onChange={(e) => setSettings({ ...settings, stockCalculationMode: e.target.value as 'automatic' | 'manual' })}
|
||||
checked={settings.stockCalculationMode === "manual"}
|
||||
onChange={(e) =>
|
||||
setSettings({ ...settings, stockCalculationMode: e.target.value as "automatic" | "manual" })
|
||||
}
|
||||
/>
|
||||
<div className="radio-card-content">
|
||||
<div className="radio-card-text">
|
||||
<span className="radio-card-title">{t('settings.stock.manual')}</span>
|
||||
<span className="radio-card-desc">{t('settings.stock.manualDesc')}</span>
|
||||
<span className="radio-card-title">{t("settings.stock.manual")}</span>
|
||||
<span className="radio-card-desc">{t("settings.stock.manualDesc")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
@@ -416,11 +512,11 @@ export function SettingsPage() {
|
||||
|
||||
<div className="setting-section">
|
||||
<div className="section-header">
|
||||
<h3>{t('settings.stock.display')}</h3>
|
||||
<h3>{t("settings.stock.display")}</h3>
|
||||
</div>
|
||||
<div className="setting-group">
|
||||
<label>
|
||||
<span className="field-label">{t('settings.stock.lowStockDays')}</span>
|
||||
<span className="field-label">{t("settings.stock.lowStockDays")}</span>
|
||||
<div className="input-with-tooltip">
|
||||
<input
|
||||
type="number"
|
||||
@@ -429,11 +525,13 @@ export function SettingsPage() {
|
||||
value={settings.lowStockDays}
|
||||
onChange={(e) => setSettings({ ...settings, lowStockDays: Number(e.target.value) || 30 })}
|
||||
/>
|
||||
<span className="info-tooltip" data-tooltip={t('settings.stock.lowStockTooltip')}>ⓘ</span>
|
||||
<span className="info-tooltip" data-tooltip={t("settings.stock.lowStockTooltip")}>
|
||||
ⓘ
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">{t('settings.stock.highStockDays')}</span>
|
||||
<span className="field-label">{t("settings.stock.highStockDays")}</span>
|
||||
<div className="input-with-tooltip">
|
||||
<input
|
||||
type="number"
|
||||
@@ -442,7 +540,9 @@ export function SettingsPage() {
|
||||
value={settings.highStockDays}
|
||||
onChange={(e) => setSettings({ ...settings, highStockDays: Number(e.target.value) || 180 })}
|
||||
/>
|
||||
<span className="info-tooltip" data-tooltip={t('settings.stock.highStockTooltip')}>ⓘ</span>
|
||||
<span className="info-tooltip" data-tooltip={t("settings.stock.highStockTooltip")}>
|
||||
ⓘ
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
@@ -453,38 +553,65 @@ export function SettingsPage() {
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<h2>
|
||||
{t('exportImport.title')}
|
||||
<span className="info-tooltip" data-tooltip={t('exportImport.description')}>ⓘ</span>
|
||||
{t("exportImport.title")}
|
||||
<span className="info-tooltip" data-tooltip={t("exportImport.description")}>
|
||||
ⓘ
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="setting-section">
|
||||
<div className="setting-group">
|
||||
{/* Import Success Message */}
|
||||
{importResult && (
|
||||
<div className="success-banner" style={{marginBottom: '16px', padding: '12px 16px', borderRadius: '8px', backgroundColor: 'var(--success-bg)', border: '1px solid var(--success)', color: 'var(--text-primary)'}}>
|
||||
<div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start'}}>
|
||||
<div
|
||||
className="success-banner"
|
||||
style={{
|
||||
marginBottom: "16px",
|
||||
padding: "12px 16px",
|
||||
borderRadius: "8px",
|
||||
backgroundColor: "var(--success-bg)",
|
||||
border: "1px solid var(--success)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||
<div>
|
||||
<strong style={{display: 'block', marginBottom: '4px', color: 'var(--success)'}}>✓ {t('exportImport.importSuccess')}</strong>
|
||||
<span style={{fontSize: '0.9em'}}>{t('exportImport.importSuccessDetails', {
|
||||
medications: importResult.medications,
|
||||
doses: importResult.doses,
|
||||
shares: importResult.shares
|
||||
})}</span>
|
||||
<strong style={{ display: "block", marginBottom: "4px", color: "var(--success)" }}>
|
||||
✓ {t("exportImport.importSuccess")}
|
||||
</strong>
|
||||
<span style={{ fontSize: "0.9em" }}>
|
||||
{t("exportImport.importSuccessDetails", {
|
||||
medications: importResult.medications,
|
||||
doses: importResult.doses,
|
||||
shares: importResult.shares,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setImportResult(null)}
|
||||
style={{background: 'none', border: 'none', cursor: 'pointer', fontSize: '1.2em', padding: '0', lineHeight: '1', color: 'inherit', opacity: 0.7}}
|
||||
onClick={() => setImportResult(null)}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
fontSize: "1.2em",
|
||||
padding: "0",
|
||||
lineHeight: "1",
|
||||
color: "inherit",
|
||||
opacity: 0.7,
|
||||
}}
|
||||
aria-label="Close"
|
||||
>×</button>
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Export */}
|
||||
<div className="action-card">
|
||||
<div className="action-card-content">
|
||||
<span className="action-card-title">{t('exportImport.exportTitle')}</span>
|
||||
<span className="action-card-desc">{t('exportImport.exportDesc')}</span>
|
||||
<span className="action-card-title">{t("exportImport.exportTitle")}</span>
|
||||
<span className="action-card-desc">{t("exportImport.exportDesc")}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -492,15 +619,15 @@ export function SettingsPage() {
|
||||
onClick={() => setShowExportModal(true)}
|
||||
disabled={exporting}
|
||||
>
|
||||
{exporting ? t('exportImport.exporting') : t('exportImport.export')}
|
||||
{exporting ? t("exportImport.exporting") : t("exportImport.export")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Import */}
|
||||
<div className="action-card">
|
||||
<div className="action-card-content">
|
||||
<span className="action-card-title">{t('exportImport.importTitle')}</span>
|
||||
<span className="action-card-desc">{t('exportImport.importDesc')}</span>
|
||||
<span className="action-card-title">{t("exportImport.importTitle")}</span>
|
||||
<span className="action-card-desc">{t("exportImport.importDesc")}</span>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
@@ -508,15 +635,15 @@ export function SettingsPage() {
|
||||
accept=".json,application/json"
|
||||
onChange={handleImportFileSelect}
|
||||
disabled={importing}
|
||||
style={{display: 'none'}}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => document.getElementById('import-file-input')?.click()}
|
||||
onClick={() => document.getElementById("import-file-input")?.click()}
|
||||
disabled={importing}
|
||||
>
|
||||
{importing ? t('exportImport.importing') : t('exportImport.import')}
|
||||
{importing ? t("exportImport.importing") : t("exportImport.import")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -525,7 +652,11 @@ export function SettingsPage() {
|
||||
|
||||
<div className="form-footer">
|
||||
<button type="submit" disabled={settingsSaving || (!settingsChanged && settingsSaved)}>
|
||||
{settingsSaving ? t('common.saving') : settingsSaved && !settingsChanged ? t('common.saved') : t('settings.saveSettings')}
|
||||
{settingsSaving
|
||||
? t("common.saving")
|
||||
: settingsSaved && !settingsChanged
|
||||
? t("common.saved")
|
||||
: t("settings.saveSettings")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -534,15 +665,15 @@ export function SettingsPage() {
|
||||
{/* Import Confirmation Modal */}
|
||||
{showImportConfirm && (
|
||||
<ConfirmModal
|
||||
title={t('exportImport.confirmImport')}
|
||||
title={t("exportImport.confirmImport")}
|
||||
message={
|
||||
<>
|
||||
<p style={{ marginBottom: "12px" }}>{t('exportImport.confirmImportMessage')}</p>
|
||||
<p className="warning-text">⚠️ {t('exportImport.confirmImportWarning')}</p>
|
||||
<p style={{ marginBottom: "12px" }}>{t("exportImport.confirmImportMessage")}</p>
|
||||
<p className="warning-text">⚠️ {t("exportImport.confirmImportWarning")}</p>
|
||||
</>
|
||||
}
|
||||
confirmLabel={t('exportImport.confirmButton')}
|
||||
cancelLabel={t('exportImport.cancelButton')}
|
||||
confirmLabel={t("exportImport.confirmButton")}
|
||||
cancelLabel={t("exportImport.cancelButton")}
|
||||
onConfirm={handleImportConfirm}
|
||||
onCancel={() => {
|
||||
setShowImportConfirm(false);
|
||||
|
||||
+3616
-2831
File diff suppressed because it is too large
Load Diff
@@ -1,72 +1,72 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import AboutModal from '../../components/AboutModal';
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import AboutModal from "../../components/AboutModal";
|
||||
|
||||
// Mock App module for constants
|
||||
vi.mock('../../App', () => ({
|
||||
FRONTEND_VERSION: '1.0.0',
|
||||
GITHUB_URL: 'https://github.com/test/repo'
|
||||
vi.mock("../../App", () => ({
|
||||
FRONTEND_VERSION: "1.0.0",
|
||||
GITHUB_URL: "https://github.com/test/repo",
|
||||
}));
|
||||
|
||||
describe('AboutModal', () => {
|
||||
const defaultProps = {
|
||||
isOpen: true,
|
||||
onClose: vi.fn()
|
||||
};
|
||||
describe("AboutModal", () => {
|
||||
const defaultProps = {
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ version: '1.0.0' })
|
||||
});
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ version: "1.0.0" }),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when not open', () => {
|
||||
const { container } = render(<AboutModal {...defaultProps} isOpen={false} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
it("returns null when not open", () => {
|
||||
const { container } = render(<AboutModal {...defaultProps} isOpen={false} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('renders when open', () => {
|
||||
render(<AboutModal {...defaultProps} />);
|
||||
expect(screen.getByText(/about\.appName/i)).toBeInTheDocument();
|
||||
});
|
||||
it("renders when open", () => {
|
||||
render(<AboutModal {...defaultProps} />);
|
||||
expect(screen.getByText(/about\.appName/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays version number', () => {
|
||||
render(<AboutModal {...defaultProps} />);
|
||||
expect(screen.getByText(/1\.0\.0/)).toBeInTheDocument();
|
||||
});
|
||||
it("displays version number", () => {
|
||||
render(<AboutModal {...defaultProps} />);
|
||||
expect(screen.getByText(/1\.0\.0/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClose when close button is clicked', () => {
|
||||
render(<AboutModal {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText('×'));
|
||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||
});
|
||||
it("calls onClose when close button is clicked", () => {
|
||||
render(<AboutModal {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText("×"));
|
||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onClose when overlay is clicked', () => {
|
||||
const { container } = render(<AboutModal {...defaultProps} />);
|
||||
const overlay = container.querySelector('.modal-overlay');
|
||||
fireEvent.click(overlay!);
|
||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||
});
|
||||
it("calls onClose when overlay is clicked", () => {
|
||||
const { container } = render(<AboutModal {...defaultProps} />);
|
||||
const overlay = container.querySelector(".modal-overlay");
|
||||
fireEvent.click(overlay!);
|
||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call onClose when modal content is clicked', () => {
|
||||
const { container } = render(<AboutModal {...defaultProps} />);
|
||||
const content = container.querySelector('.about-modal');
|
||||
if (content) {
|
||||
fireEvent.click(content);
|
||||
expect(defaultProps.onClose).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
it("does not call onClose when modal content is clicked", () => {
|
||||
const { container } = render(<AboutModal {...defaultProps} />);
|
||||
const content = container.querySelector(".about-modal");
|
||||
if (content) {
|
||||
fireEvent.click(content);
|
||||
expect(defaultProps.onClose).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('renders GitHub link', () => {
|
||||
render(<AboutModal {...defaultProps} />);
|
||||
const links = screen.getAllByRole('link');
|
||||
expect(links.length).toBeGreaterThan(0);
|
||||
});
|
||||
it("renders GitHub link", () => {
|
||||
render(<AboutModal {...defaultProps} />);
|
||||
const links = screen.getAllByRole("link");
|
||||
expect(links.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('fetches backend version on open', async () => {
|
||||
render(<AboutModal {...defaultProps} />);
|
||||
expect(fetch).toHaveBeenCalledWith('/api/health');
|
||||
});
|
||||
it("fetches backend version on open", async () => {
|
||||
render(<AboutModal {...defaultProps} />);
|
||||
expect(fetch).toHaveBeenCalledWith("/api/health");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,250 +1,263 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { AppHeader } from '../../components/AppHeader';
|
||||
import { AuthProvider } from '../../components/Auth';
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AppHeader } from "../../components/AppHeader";
|
||||
import { AuthProvider } from "../../components/Auth";
|
||||
|
||||
// Mock useNavigate
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
vi.mock("react-router-dom", async () => {
|
||||
const actual = await vi.importActual("react-router-dom");
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
describe('AppHeader', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockNavigate.mockClear();
|
||||
// Set up default auth mock - auth disabled
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
authEnabled: false,
|
||||
localAuthEnabled: true,
|
||||
hasUsers: false,
|
||||
needsSetup: false
|
||||
})
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 401,
|
||||
ok: false
|
||||
});
|
||||
});
|
||||
// Mock useUnsavedChanges
|
||||
vi.mock("../../context", () => ({
|
||||
useUnsavedChanges: () => ({
|
||||
setHasUnsavedChanges: vi.fn(),
|
||||
hasUnsavedChanges: false,
|
||||
confirmNavigation: vi.fn().mockReturnValue(true),
|
||||
}),
|
||||
}));
|
||||
|
||||
it('renders header with logo', async () => {
|
||||
const mockOnOpenProfile = vi.fn();
|
||||
const mockOnOpenAbout = vi.fn();
|
||||
describe("AppHeader", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockNavigate.mockClear();
|
||||
// Set up default auth mock - auth disabled
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
authEnabled: false,
|
||||
localAuthEnabled: true,
|
||||
hasUsers: false,
|
||||
needsSetup: false,
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 401,
|
||||
ok: false,
|
||||
});
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/dashboard']}>
|
||||
<AuthProvider>
|
||||
<AppHeader onOpenProfile={mockOnOpenProfile} onOpenAbout={mockOnOpenAbout} />
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const logo = screen.getByAltText('MedAssist-ng');
|
||||
expect(logo).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it("renders header with logo", async () => {
|
||||
const mockOnOpenProfile = vi.fn();
|
||||
const mockOnOpenAbout = vi.fn();
|
||||
|
||||
it('renders navigation tabs', async () => {
|
||||
const mockOnOpenProfile = vi.fn();
|
||||
const mockOnOpenAbout = vi.fn();
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/dashboard"]}>
|
||||
<AuthProvider>
|
||||
<AppHeader onOpenProfile={mockOnOpenProfile} onOpenAbout={mockOnOpenAbout} />
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/dashboard']}>
|
||||
<AuthProvider>
|
||||
<AppHeader onOpenProfile={mockOnOpenProfile} onOpenAbout={mockOnOpenAbout} />
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
// Use getAllBy since there are multiple elements with same text
|
||||
const dashboardElements = screen.getAllByText(/nav\.dashboard/i);
|
||||
expect(dashboardElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
await waitFor(() => {
|
||||
const logo = screen.getByAltText("MedAssist-ng");
|
||||
expect(logo).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders theme toggle button', async () => {
|
||||
const mockOnOpenProfile = vi.fn();
|
||||
const mockOnOpenAbout = vi.fn();
|
||||
it("renders navigation tabs", async () => {
|
||||
const mockOnOpenProfile = vi.fn();
|
||||
const mockOnOpenAbout = vi.fn();
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/dashboard']}>
|
||||
<AuthProvider>
|
||||
<AppHeader onOpenProfile={mockOnOpenProfile} onOpenAbout={mockOnOpenAbout} />
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const themeBtn = buttons.find(btn => btn.textContent?.includes('🌙') || btn.textContent?.includes('☀️'));
|
||||
expect(themeBtn).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/dashboard"]}>
|
||||
<AuthProvider>
|
||||
<AppHeader onOpenProfile={mockOnOpenProfile} onOpenAbout={mockOnOpenAbout} />
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
it('renders settings button when auth is disabled', async () => {
|
||||
const mockOnOpenProfile = vi.fn();
|
||||
const mockOnOpenAbout = vi.fn();
|
||||
await waitFor(() => {
|
||||
// Use getAllBy since there are multiple elements with same text
|
||||
const dashboardElements = screen.getAllByText(/nav\.dashboard/i);
|
||||
expect(dashboardElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/dashboard']}>
|
||||
<AuthProvider>
|
||||
<AppHeader onOpenProfile={mockOnOpenProfile} onOpenAbout={mockOnOpenAbout} />
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const settingsBtn = screen.queryByTitle(/nav\.settings/i);
|
||||
expect(settingsBtn).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it("renders theme toggle button", async () => {
|
||||
const mockOnOpenProfile = vi.fn();
|
||||
const mockOnOpenAbout = vi.fn();
|
||||
|
||||
it('shows page eyebrow and title', async () => {
|
||||
const mockOnOpenProfile = vi.fn();
|
||||
const mockOnOpenAbout = vi.fn();
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/dashboard"]}>
|
||||
<AuthProvider>
|
||||
<AppHeader onOpenProfile={mockOnOpenProfile} onOpenAbout={mockOnOpenAbout} />
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/dashboard']}>
|
||||
<AuthProvider>
|
||||
<AppHeader onOpenProfile={mockOnOpenProfile} onOpenAbout={mockOnOpenAbout} />
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/header\.eyebrow\.overview/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
await waitFor(() => {
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const themeBtn = buttons.find((btn) => btn.textContent?.includes("🌙") || btn.textContent?.includes("☀️"));
|
||||
expect(themeBtn).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows medications page title on medications route', async () => {
|
||||
const mockOnOpenProfile = vi.fn();
|
||||
const mockOnOpenAbout = vi.fn();
|
||||
it("renders settings button when auth is disabled", async () => {
|
||||
const mockOnOpenProfile = vi.fn();
|
||||
const mockOnOpenAbout = vi.fn();
|
||||
|
||||
// Reset mock for this test
|
||||
vi.clearAllMocks();
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
authEnabled: false,
|
||||
localAuthEnabled: true,
|
||||
hasUsers: false,
|
||||
needsSetup: false
|
||||
})
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 401,
|
||||
ok: false
|
||||
});
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/dashboard"]}>
|
||||
<AuthProvider>
|
||||
<AppHeader onOpenProfile={mockOnOpenProfile} onOpenAbout={mockOnOpenAbout} />
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/medications']}>
|
||||
<AuthProvider>
|
||||
<AppHeader onOpenProfile={mockOnOpenProfile} onOpenAbout={mockOnOpenAbout} />
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/header\.eyebrow\.inventory/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
await waitFor(() => {
|
||||
const settingsBtn = screen.queryByTitle(/nav\.settings/i);
|
||||
expect(settingsBtn).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows planner page title on planner route', async () => {
|
||||
const mockOnOpenProfile = vi.fn();
|
||||
const mockOnOpenAbout = vi.fn();
|
||||
it("shows page eyebrow and title", async () => {
|
||||
const mockOnOpenProfile = vi.fn();
|
||||
const mockOnOpenAbout = vi.fn();
|
||||
|
||||
vi.clearAllMocks();
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
authEnabled: false,
|
||||
localAuthEnabled: true,
|
||||
hasUsers: false,
|
||||
needsSetup: false
|
||||
})
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 401,
|
||||
ok: false
|
||||
});
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/dashboard"]}>
|
||||
<AuthProvider>
|
||||
<AppHeader onOpenProfile={mockOnOpenProfile} onOpenAbout={mockOnOpenAbout} />
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/planner']}>
|
||||
<AuthProvider>
|
||||
<AppHeader onOpenProfile={mockOnOpenProfile} onOpenAbout={mockOnOpenAbout} />
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/header\.eyebrow\.planner/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/header\.eyebrow\.overview/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows settings page title on settings route', async () => {
|
||||
const mockOnOpenProfile = vi.fn();
|
||||
const mockOnOpenAbout = vi.fn();
|
||||
it("shows medications page title on medications route", async () => {
|
||||
const mockOnOpenProfile = vi.fn();
|
||||
const mockOnOpenAbout = vi.fn();
|
||||
|
||||
vi.clearAllMocks();
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
authEnabled: false,
|
||||
localAuthEnabled: true,
|
||||
hasUsers: false,
|
||||
needsSetup: false
|
||||
})
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 401,
|
||||
ok: false
|
||||
});
|
||||
// Reset mock for this test
|
||||
vi.clearAllMocks();
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
authEnabled: false,
|
||||
localAuthEnabled: true,
|
||||
hasUsers: false,
|
||||
needsSetup: false,
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 401,
|
||||
ok: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/settings']}>
|
||||
<AuthProvider>
|
||||
<AppHeader onOpenProfile={mockOnOpenProfile} onOpenAbout={mockOnOpenAbout} />
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/header\.eyebrow\.settings/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/medications"]}>
|
||||
<AuthProvider>
|
||||
<AppHeader onOpenProfile={mockOnOpenProfile} onOpenAbout={mockOnOpenAbout} />
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
it('navigates when tab clicked', async () => {
|
||||
const mockOnOpenProfile = vi.fn();
|
||||
const mockOnOpenAbout = vi.fn();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/header\.eyebrow\.inventory/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/dashboard']}>
|
||||
<AuthProvider>
|
||||
<AppHeader onOpenProfile={mockOnOpenProfile} onOpenAbout={mockOnOpenAbout} />
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const medsBtn = buttons.find(btn => btn.textContent?.includes('nav.medications'));
|
||||
if (medsBtn) {
|
||||
fireEvent.click(medsBtn);
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/medications');
|
||||
}
|
||||
});
|
||||
});
|
||||
it("shows planner page title on planner route", async () => {
|
||||
const mockOnOpenProfile = vi.fn();
|
||||
const mockOnOpenAbout = vi.fn();
|
||||
|
||||
vi.clearAllMocks();
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
authEnabled: false,
|
||||
localAuthEnabled: true,
|
||||
hasUsers: false,
|
||||
needsSetup: false,
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 401,
|
||||
ok: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/planner"]}>
|
||||
<AuthProvider>
|
||||
<AppHeader onOpenProfile={mockOnOpenProfile} onOpenAbout={mockOnOpenAbout} />
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/header\.eyebrow\.planner/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows settings page title on settings route", async () => {
|
||||
const mockOnOpenProfile = vi.fn();
|
||||
const mockOnOpenAbout = vi.fn();
|
||||
|
||||
vi.clearAllMocks();
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
authEnabled: false,
|
||||
localAuthEnabled: true,
|
||||
hasUsers: false,
|
||||
needsSetup: false,
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 401,
|
||||
ok: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/settings"]}>
|
||||
<AuthProvider>
|
||||
<AppHeader onOpenProfile={mockOnOpenProfile} onOpenAbout={mockOnOpenAbout} />
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/header\.eyebrow\.settings/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("navigates when tab clicked", async () => {
|
||||
const mockOnOpenProfile = vi.fn();
|
||||
const mockOnOpenAbout = vi.fn();
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/dashboard"]}>
|
||||
<AuthProvider>
|
||||
<AppHeader onOpenProfile={mockOnOpenProfile} onOpenAbout={mockOnOpenAbout} />
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const medsBtn = buttons.find((btn) => btn.textContent?.includes("nav.medications"));
|
||||
if (medsBtn) {
|
||||
fireEvent.click(medsBtn);
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/medications");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,359 +1,381 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { AuthProvider, useAuth, LoginForm, RegisterForm, UserProfile, AuthPage } from '../../components/Auth';
|
||||
import React from 'react';
|
||||
import { fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AuthPage, AuthProvider, LoginForm, RegisterForm, UserProfile, useAuth } from "../../components/Auth";
|
||||
|
||||
// Wrapper component for testing hooks that require AuthProvider
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
);
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => <AuthProvider>{children}</AuthProvider>;
|
||||
|
||||
describe('AuthProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ authEnabled: true, localAuthEnabled: true })
|
||||
});
|
||||
});
|
||||
describe("AuthProvider", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ authEnabled: true, localAuthEnabled: true }),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('provides auth context to children', () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<div data-testid="child">Child content</div>
|
||||
</AuthProvider>
|
||||
);
|
||||
expect(screen.getByTestId('child')).toBeInTheDocument();
|
||||
});
|
||||
it("provides auth context to children", () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<div data-testid="child">Child content</div>
|
||||
</AuthProvider>
|
||||
);
|
||||
expect(screen.getByTestId("child")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('initializes with loading state', () => {
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
// Initially loading
|
||||
expect(result.current.loading).toBe(true);
|
||||
});
|
||||
it("initializes with loading state", () => {
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
// Initially loading
|
||||
expect(result.current.loading).toBe(true);
|
||||
});
|
||||
|
||||
it('fetches auth state on mount', async () => {
|
||||
renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetch).toHaveBeenCalledWith('/api/auth/state');
|
||||
});
|
||||
});
|
||||
it("fetches auth state on mount", async () => {
|
||||
renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
it('throws error when useAuth is used outside AuthProvider', () => {
|
||||
expect(() => {
|
||||
renderHook(() => useAuth());
|
||||
}).toThrow('useAuth must be used within AuthProvider');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(fetch).toHaveBeenCalledWith("/api/auth/state");
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches auth state only ONCE on mount (no infinite loop)", async () => {
|
||||
// This test catches the infinite loop bug where fetchAuthState was in useEffect dependencies
|
||||
vi.clearAllMocks();
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ authEnabled: false }),
|
||||
});
|
||||
|
||||
renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
// Wait for the initial fetch to complete
|
||||
await waitFor(() => {
|
||||
expect(fetch).toHaveBeenCalledWith("/api/auth/state");
|
||||
});
|
||||
|
||||
// Wait a bit more to ensure no additional calls happen
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// Should be called exactly once, not multiple times (which would indicate infinite loop)
|
||||
const authStateCalls = (fetch as ReturnType<typeof vi.fn>).mock.calls.filter(
|
||||
(call) => call[0] === "/api/auth/state"
|
||||
);
|
||||
expect(authStateCalls.length).toBe(1);
|
||||
});
|
||||
|
||||
it("throws error when useAuth is used outside AuthProvider", () => {
|
||||
expect(() => {
|
||||
renderHook(() => useAuth());
|
||||
}).toThrow("useAuth must be used within AuthProvider");
|
||||
});
|
||||
});
|
||||
|
||||
describe('LoginForm', () => {
|
||||
const mockAuthState = {
|
||||
authEnabled: true,
|
||||
localAuthEnabled: true,
|
||||
oidcEnabled: false,
|
||||
registrationEnabled: true,
|
||||
hasUsers: true,
|
||||
needsSetup: false,
|
||||
oidcProviderName: ''
|
||||
};
|
||||
describe("LoginForm", () => {
|
||||
const mockAuthState = {
|
||||
authEnabled: true,
|
||||
localAuthEnabled: true,
|
||||
oidcEnabled: false,
|
||||
registrationEnabled: true,
|
||||
hasUsers: true,
|
||||
needsSetup: false,
|
||||
oidcProviderName: "",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockAuthState)
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 401,
|
||||
ok: false
|
||||
});
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockAuthState),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 401,
|
||||
ok: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders login form', async () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<LoginForm />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/MedAssist/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it("renders login form", async () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<LoginForm />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
it('renders username and password fields', async () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<LoginForm />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/auth\.username/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/auth\.password/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/MedAssist/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders remember me checkbox', async () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<LoginForm />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/auth\.rememberMe/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it("renders username and password fields", async () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<LoginForm />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
it('renders create account link when registration enabled', async () => {
|
||||
const onSwitchToRegister = vi.fn();
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<LoginForm onSwitchToRegister={onSwitchToRegister} />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const createAccountBtn = screen.getByText(/auth\.createAccount/i);
|
||||
expect(createAccountBtn).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/auth\.username/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/auth\.password/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles form input changes', async () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<LoginForm />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/auth\.username/i)).toBeInTheDocument();
|
||||
});
|
||||
it("renders remember me checkbox", async () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<LoginForm />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/auth\.username/i), { target: { value: 'testuser' } });
|
||||
fireEvent.change(screen.getByLabelText(/auth\.password/i), { target: { value: 'password123' } });
|
||||
|
||||
expect(screen.getByLabelText(/auth\.username/i)).toHaveValue('testuser');
|
||||
expect(screen.getByLabelText(/auth\.password/i)).toHaveValue('password123');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/auth\.rememberMe/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders submit button', async () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<LoginForm />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const submitBtn = buttons.find(btn => btn.getAttribute('type') === 'submit');
|
||||
expect(submitBtn).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it("renders create account link when registration enabled", async () => {
|
||||
const onSwitchToRegister = vi.fn();
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<LoginForm onSwitchToRegister={onSwitchToRegister} />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const createAccountBtn = screen.getByText(/auth\.createAccount/i);
|
||||
expect(createAccountBtn).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("handles form input changes", async () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<LoginForm />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/auth\.username/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/auth\.username/i), { target: { value: "testuser" } });
|
||||
fireEvent.change(screen.getByLabelText(/auth\.password/i), { target: { value: "password123" } });
|
||||
|
||||
expect(screen.getByLabelText(/auth\.username/i)).toHaveValue("testuser");
|
||||
expect(screen.getByLabelText(/auth\.password/i)).toHaveValue("password123");
|
||||
});
|
||||
|
||||
it("renders submit button", async () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<LoginForm />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const submitBtn = buttons.find((btn) => btn.getAttribute("type") === "submit");
|
||||
expect(submitBtn).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('RegisterForm', () => {
|
||||
const mockAuthState = {
|
||||
authEnabled: true,
|
||||
localAuthEnabled: true,
|
||||
oidcEnabled: false,
|
||||
registrationEnabled: true,
|
||||
hasUsers: false,
|
||||
needsSetup: true,
|
||||
oidcProviderName: ''
|
||||
};
|
||||
describe("RegisterForm", () => {
|
||||
const mockAuthState = {
|
||||
authEnabled: true,
|
||||
localAuthEnabled: true,
|
||||
oidcEnabled: false,
|
||||
registrationEnabled: true,
|
||||
hasUsers: false,
|
||||
needsSetup: true,
|
||||
oidcProviderName: "",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockAuthState)
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 401,
|
||||
ok: false
|
||||
});
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockAuthState),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 401,
|
||||
ok: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders registration form', async () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<RegisterForm />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/MedAssist/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it("renders registration form", async () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<RegisterForm />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
it('renders all required fields', async () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<RegisterForm />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
// Check for username field
|
||||
expect(screen.getByLabelText(/auth\.username/i)).toBeInTheDocument();
|
||||
// Check for password field
|
||||
expect(screen.getByLabelText(/auth\.password/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/MedAssist/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders switch to login link', async () => {
|
||||
const onSwitchToLogin = vi.fn();
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<RegisterForm onSwitchToLogin={onSwitchToLogin} />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const loginLink = screen.getByText(/auth\.alreadyHaveAccount/i);
|
||||
expect(loginLink).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it("renders all required fields", async () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<RegisterForm />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
it('calls onSwitchToLogin when clicked', async () => {
|
||||
const onSwitchToLogin = vi.fn();
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<RegisterForm onSwitchToLogin={onSwitchToLogin} />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const loginLink = screen.getByText(/auth\.alreadyHaveAccount/i);
|
||||
fireEvent.click(loginLink);
|
||||
});
|
||||
await waitFor(() => {
|
||||
// Check for username field
|
||||
expect(screen.getByLabelText(/auth\.username/i)).toBeInTheDocument();
|
||||
// Check for password field
|
||||
expect(screen.getByLabelText(/auth\.password/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
expect(onSwitchToLogin).toHaveBeenCalled();
|
||||
});
|
||||
it("renders switch to login link", async () => {
|
||||
const onSwitchToLogin = vi.fn();
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<RegisterForm onSwitchToLogin={onSwitchToLogin} />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const loginLink = screen.getByText(/auth\.alreadyHaveAccount/i);
|
||||
expect(loginLink).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls onSwitchToLogin when clicked", async () => {
|
||||
const onSwitchToLogin = vi.fn();
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<RegisterForm onSwitchToLogin={onSwitchToLogin} />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const loginLink = screen.getByText(/auth\.alreadyHaveAccount/i);
|
||||
fireEvent.click(loginLink);
|
||||
});
|
||||
|
||||
expect(onSwitchToLogin).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthPage', () => {
|
||||
const mockAuthState = {
|
||||
authEnabled: true,
|
||||
localAuthEnabled: true,
|
||||
oidcEnabled: false,
|
||||
registrationEnabled: true,
|
||||
hasUsers: true,
|
||||
needsSetup: false,
|
||||
oidcProviderName: ''
|
||||
};
|
||||
describe("AuthPage", () => {
|
||||
const mockAuthState = {
|
||||
authEnabled: true,
|
||||
localAuthEnabled: true,
|
||||
oidcEnabled: false,
|
||||
registrationEnabled: true,
|
||||
hasUsers: true,
|
||||
needsSetup: false,
|
||||
oidcProviderName: "",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockAuthState)
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 401,
|
||||
ok: false
|
||||
});
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockAuthState),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 401,
|
||||
ok: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders login form by default', async () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<AuthPage />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show login form with username field
|
||||
expect(screen.getByLabelText(/auth\.username/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it("renders login form by default", async () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<AuthPage />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show login form with username field
|
||||
expect(screen.getByLabelText(/auth\.username/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('UserProfile', () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
username: 'testuser',
|
||||
avatarUrl: null
|
||||
};
|
||||
describe("UserProfile", () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
username: "testuser",
|
||||
avatarUrl: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ authEnabled: true, localAuthEnabled: true })
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockUser)
|
||||
});
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ authEnabled: true, localAuthEnabled: true }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockUser),
|
||||
});
|
||||
});
|
||||
|
||||
it('renders user profile when user is logged in', async () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<UserProfile />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('testuser')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it("renders user profile when user is logged in", async () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<UserProfile />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
it('displays user avatar initial when no avatar', async () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<UserProfile />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
// The avatar shows first letter of username
|
||||
expect(screen.getByText('T')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("testuser")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders change password section', async () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<UserProfile />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/auth\.changePassword/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it("displays user avatar initial when no avatar", async () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<UserProfile />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
it('renders cancel button that calls onClose', async () => {
|
||||
const onClose = vi.fn();
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<UserProfile onClose={onClose} />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const cancelBtn = screen.getByText(/common\.cancel/i);
|
||||
fireEvent.click(cancelBtn);
|
||||
});
|
||||
await waitFor(() => {
|
||||
// The avatar shows first letter of username
|
||||
expect(screen.getByText("T")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
it("renders change password section", async () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<UserProfile />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/auth\.changePassword/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders cancel button that calls onClose", async () => {
|
||||
const onClose = vi.fn();
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<UserProfile onClose={onClose} />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const cancelBtn = screen.getByText(/common\.cancel/i);
|
||||
fireEvent.click(cancelBtn);
|
||||
});
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user