Added redis and worker services

This commit is contained in:
blaisadmin
2026-05-02 00:14:56 -04:00
parent 5a3ca9021a
commit 673df353b9
15 changed files with 833 additions and 15 deletions
+2
View File
@@ -1,6 +1,7 @@
POSTGRES_DB=flockpal POSTGRES_DB=flockpal
POSTGRES_USER=flockpal POSTGRES_USER=flockpal
POSTGRES_PASSWORD=change_me_for_production POSTGRES_PASSWORD=change_me_for_production
REDIS_URL=redis://redis:6379
FRONTEND_URL=http://localhost:3000 FRONTEND_URL=http://localhost:3000
BACKEND_URL=http://localhost:5000 BACKEND_URL=http://localhost:5000
VITE_API_BASE_URL=http://localhost:5000/api VITE_API_BASE_URL=http://localhost:5000/api
@@ -8,6 +9,7 @@ NODE_ENV=development
TRUST_PROXY= TRUST_PROXY=
ADMIN_EMAILS=corey@blaishome.online ADMIN_EMAILS=corey@blaishome.online
RESCUE_STATUS_NOTIFICATION_EMAIL=appadmin@flockpal.app RESCUE_STATUS_NOTIFICATION_EMAIL=appadmin@flockpal.app
RESCUE_ONBOARDING_WEBHOOK_URL=https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee
MILESTONE_REMINDERS_ENABLED=true MILESTONE_REMINDERS_ENABLED=true
MILESTONE_REMINDER_TIME_ZONE=America/New_York MILESTONE_REMINDER_TIME_ZONE=America/New_York
STRIPE_SECRET_KEY= STRIPE_SECRET_KEY=
+1
View File
@@ -5,4 +5,5 @@ frontend/node_modules
backend/dist backend/dist
frontend/dist frontend/dist
data/ data/
backups/
.DS_Store .DS_Store
+63 -1
View File
@@ -18,6 +18,8 @@ FlockPal is a Dockerized TypeScript app for tracking flock health with a clean,
- Vet visit history with notes - Vet visit history with notes
- Postgres-backed storage - Postgres-backed storage
- React frontend and Express backend - React frontend and Express backend
- Separate worker process for scheduled reminders and background tasks
- Redis-backed background queue for scheduled reminders
- Security-minded defaults like Helmet, CORS allow-listing, rate limiting, and input validation - Security-minded defaults like Helmet, CORS allow-listing, rate limiting, and input validation
## Planned next steps ## Planned next steps
@@ -43,6 +45,40 @@ Full API documentation is available in [docs/API_REFERENCE.md](docs/API_REFERENC
The default `docker-compose.yml` is development-only. It mounts source files, installs dev dependencies, and runs the backend and frontend in watch mode. The default `docker-compose.yml` is development-only. It mounts source files, installs dev dependencies, and runs the backend and frontend in watch mode.
## Operations
### Backups
Create a compressed Postgres backup from the Docker Compose Postgres service:
```bash
./scripts/backup-postgres.sh
```
By default this uses `docker-compose.prod.yml` and writes to `backups/postgres/`. Override with `COMPOSE_FILE` or `BACKUP_DIR`:
```bash
COMPOSE_FILE=docker-compose.yml BACKUP_DIR=/srv/flockpal-backups ./scripts/backup-postgres.sh
```
Test a backup by restoring it into a temporary database in the same Postgres container:
```bash
./scripts/restore-test-postgres.sh backups/postgres/flockpal-YYYYMMDDTHHMMSSZ.dump
```
The restore test creates a temporary database, runs `pg_restore`, verifies it can query `workspaces`, and then drops the temporary database. It does not overwrite the live database.
### Metrics and logs
The backend writes HTTP access logs through Morgan. Production uses the standard combined log format so Docker, Traefik, or a log shipper can collect it from container stdout.
Admin users can fetch lightweight process/request metrics:
```bash
curl -H "Authorization: Bearer <admin-token>" https://your-host/api/metrics
```
## Production ## Production
1. Set production values in your environment or `.env`, especially: 1. Set production values in your environment or `.env`, especially:
@@ -50,6 +86,8 @@ The default `docker-compose.yml` is development-only. It mounts source files, in
- `FRONTEND_URL` - `FRONTEND_URL`
- `BACKEND_URL` - `BACKEND_URL`
- `VITE_API_BASE_URL` - `VITE_API_BASE_URL`
- `REDIS_URL`
- `RESCUE_ONBOARDING_WEBHOOK_URL`
2. Build and start the production stack: 2. Build and start the production stack:
```bash ```bash
@@ -57,7 +95,31 @@ docker compose -f docker-compose.prod.yml up --build -d
``` ```
3. The production backend runs the compiled Node app from `dist/app.js`. 3. The production backend runs the compiled Node app from `dist/app.js`.
4. The production frontend is built with Vite and served by Nginx on port `3000`. 4. The production worker runs `dist/worker.js` and owns scheduled reminder execution.
5. The production frontend is built with Vite and served by Nginx on port `3000`.
## Redis
Compose includes a Redis service at `redis://redis:6379` and passes that value through `REDIS_URL` to the backend and worker. Redis uses append-only persistence under `data/redis/`.
Scheduled milestone reminders are enqueued through Redis with a per-date job id, then processed by the worker. This keeps scheduled work out of API containers and prevents duplicate scheduled jobs when the API is scaled horizontally. Redis can also support later shared rate-limit state and short-lived cache entries.
## Worker process
The API container does not run scheduled reminder loops. Background reminders run in the `worker` service so the API can be scaled horizontally without multiple API containers sending duplicate scheduled emails.
Run the worker locally through Compose:
```bash
docker compose up worker
```
Run the compiled worker directly from the backend package:
```bash
npm run build
npm run worker
```
## Auth and flock notes ## Auth and flock notes
+307
View File
@@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@types/nodemailer": "^8.0.0", "@types/nodemailer": "^8.0.0",
"bullmq": "^5.76.4",
"cors": "2.8.5", "cors": "2.8.5",
"dotenv": "16.4.5", "dotenv": "16.4.5",
"express": "4.21.2", "express": "4.21.2",
@@ -438,6 +439,90 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@ioredis/commands": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz",
"integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==",
"license": "MIT"
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
"integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
"integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
"integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
"integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
"integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
"integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@types/body-parser": { "node_modules/@types/body-parser": {
"version": "1.19.6", "version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -638,6 +723,23 @@
"npm": "1.2.8000 || >= 1.4.16" "npm": "1.2.8000 || >= 1.4.16"
} }
}, },
"node_modules/bullmq": {
"version": "5.76.4",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.76.4.tgz",
"integrity": "sha512-hVAplia7zfN3BxSCgAoRInJnbemfLwJdQLqJy/txEX8UMSTAeg0saPFNGWIlzES/Ct5xQ20TUaik/XwS99DOMA==",
"license": "MIT",
"dependencies": {
"cron-parser": "4.9.0",
"ioredis": "5.10.1",
"msgpackr": "1.11.5",
"node-abort-controller": "3.1.1",
"semver": "7.7.4",
"tslib": "2.8.1"
},
"engines": {
"node": ">=12.22.0"
}
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -676,6 +778,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/content-disposition": { "node_modules/content-disposition": {
"version": "0.5.4", "version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -725,6 +836,18 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/cron-parser": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
"integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==",
"license": "MIT",
"dependencies": {
"luxon": "^3.2.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "2.6.9", "version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -734,6 +857,15 @@
"ms": "2.0.0" "ms": "2.0.0"
} }
}, },
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
}
},
"node_modules/depd": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -753,6 +885,16 @@
"npm": "1.2.8000 || >= 1.4.16" "npm": "1.2.8000 || >= 1.4.16"
} }
}, },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "16.4.5", "version": "16.4.5",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz",
@@ -1129,6 +1271,53 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ioredis": {
"version": "5.10.1",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz",
"integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==",
"license": "MIT",
"dependencies": {
"@ioredis/commands": "1.5.1",
"cluster-key-slot": "^1.1.0",
"debug": "^4.3.4",
"denque": "^2.1.0",
"lodash.defaults": "^4.2.0",
"lodash.isarguments": "^3.1.0",
"redis-errors": "^1.2.0",
"redis-parser": "^3.0.0",
"standard-as-callback": "^2.1.0"
},
"engines": {
"node": ">=12.22.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/ioredis"
}
},
"node_modules/ioredis/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/ioredis/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -1138,6 +1327,27 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
"license": "MIT"
},
"node_modules/lodash.isarguments": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
"license": "MIT"
},
"node_modules/luxon": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -1241,6 +1451,37 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/msgpackr": {
"version": "1.11.5",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz",
"integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==",
"license": "MIT",
"optionalDependencies": {
"msgpackr-extract": "^3.0.2"
}
},
"node_modules/msgpackr-extract": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
"integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-gyp-build-optional-packages": "5.2.2"
},
"bin": {
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
},
"optionalDependencies": {
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
}
},
"node_modules/negotiator": { "node_modules/negotiator": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -1250,6 +1491,27 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/node-abort-controller": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz",
"integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==",
"license": "MIT"
},
"node_modules/node-gyp-build-optional-packages": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.1"
},
"bin": {
"node-gyp-build-optional-packages": "bin.js",
"node-gyp-build-optional-packages-optional": "optional.js",
"node-gyp-build-optional-packages-test": "build-test.js"
}
},
"node_modules/nodemailer": { "node_modules/nodemailer": {
"version": "8.0.5", "version": "8.0.5",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz",
@@ -1582,6 +1844,27 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/redis-errors": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/redis-parser": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
"license": "MIT",
"dependencies": {
"redis-errors": "^1.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/resolve-pkg-maps": { "node_modules/resolve-pkg-maps": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
@@ -1618,6 +1901,18 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": { "node_modules/send": {
"version": "0.19.0", "version": "0.19.0",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
@@ -1759,6 +2054,12 @@
"node": ">= 10.x" "node": ">= 10.x"
} }
}, },
"node_modules/standard-as-callback": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
"license": "MIT"
},
"node_modules/statuses": { "node_modules/statuses": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
@@ -1794,6 +2095,12 @@
"node": ">=0.6" "node": ">=0.6"
} }
}, },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tsx": { "node_modules/tsx": {
"version": "4.19.2", "version": "4.19.2",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.2.tgz", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.2.tgz",
+4 -1
View File
@@ -5,12 +5,15 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "tsx watch src/app.ts", "dev": "tsx watch src/app.ts",
"worker:dev": "tsx watch src/worker.ts",
"build": "tsc", "build": "tsc",
"test": "tsx --test src/**/*.test.ts", "test": "tsx --test src/**/*.test.ts",
"start": "node dist/app.js" "start": "node dist/app.js",
"worker": "node dist/worker.js"
}, },
"dependencies": { "dependencies": {
"@types/nodemailer": "^8.0.0", "@types/nodemailer": "^8.0.0",
"bullmq": "^5.76.4",
"cors": "2.8.5", "cors": "2.8.5",
"dotenv": "16.4.5", "dotenv": "16.4.5",
"express": "4.21.2", "express": "4.21.2",
+83 -11
View File
@@ -1,6 +1,7 @@
import crypto from 'crypto'; import crypto from 'crypto';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url';
import cors from 'cors'; import cors from 'cors';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import express, { type NextFunction, type Request, type Response } from 'express'; import express, { type NextFunction, type Request, type Response } from 'express';
@@ -12,6 +13,7 @@ import Stripe from 'stripe';
import { z } from 'zod'; import { z } from 'zod';
import { ensureSchema } from './db/schema.js'; import { ensureSchema } from './db/schema.js';
import { enqueueBirdMilestoneReminderJob, getBirdMilestoneReminderQueueCounts } from './queues/birdMilestoneReminderQueue.js';
import { import {
consumeMagicLinkToken, consumeMagicLinkToken,
consumeOAuthState, consumeOAuthState,
@@ -337,7 +339,8 @@ const smtpPass = process.env.SMTP_PASS?.trim() ?? '';
const smtpFromEmail = process.env.SMTP_FROM_EMAIL?.trim() ?? ''; const smtpFromEmail = process.env.SMTP_FROM_EMAIL?.trim() ?? '';
const smtpFromName = process.env.SMTP_FROM_NAME?.trim() || 'FlockPal'; const smtpFromName = process.env.SMTP_FROM_NAME?.trim() || 'FlockPal';
const rescueStatusNotificationEmail = process.env.RESCUE_STATUS_NOTIFICATION_EMAIL?.trim() || 'appadmin@flockpal.app'; const rescueStatusNotificationEmail = process.env.RESCUE_STATUS_NOTIFICATION_EMAIL?.trim() || 'appadmin@flockpal.app';
const rescueOnboardingWebhookUrl = 'https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee'; const rescueOnboardingWebhookUrl =
process.env.RESCUE_ONBOARDING_WEBHOOK_URL?.trim() || 'https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee';
const stripeSecretKey = process.env.STRIPE_SECRET_KEY?.trim() ?? ''; const stripeSecretKey = process.env.STRIPE_SECRET_KEY?.trim() ?? '';
const stripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET?.trim() ?? ''; const stripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET?.trim() ?? '';
const withBillingRedirectState = (url: string, billingState: 'success' | 'cancelled' | 'portal') => { const withBillingRedirectState = (url: string, billingState: 'success' | 'cancelled' | 'portal') => {
@@ -690,6 +693,40 @@ app.use(express.json({ limit: '2mb' }));
app.use(express.urlencoded({ extended: false })); app.use(express.urlencoded({ extended: false }));
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev')); app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
const requestMetrics = {
startedAt: new Date().toISOString(),
totalRequests: 0,
totalErrors: 0,
inFlightRequests: 0,
totalDurationMs: 0,
byStatus: {} as Record<string, number>,
byRoute: {} as Record<string, number>,
};
app.use((req: Request, res: Response, next: NextFunction) => {
const startedAt = process.hrtime.bigint();
requestMetrics.totalRequests += 1;
requestMetrics.inFlightRequests += 1;
res.on('finish', () => {
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
requestMetrics.inFlightRequests -= 1;
requestMetrics.totalDurationMs += durationMs;
const statusBucket = `${Math.floor(res.statusCode / 100)}xx`;
requestMetrics.byStatus[statusBucket] = (requestMetrics.byStatus[statusBucket] ?? 0) + 1;
if (res.statusCode >= 500) {
requestMetrics.totalErrors += 1;
}
const routeKey = `${req.method} ${req.route?.path ?? req.path}`;
requestMetrics.byRoute[routeKey] = (requestMetrics.byRoute[routeKey] ?? 0) + 1;
});
next();
});
const normalizeWorkspaceMembershipList = async (userId: string) => const normalizeWorkspaceMembershipList = async (userId: string) =>
(await listMembershipsForUser(userId)).map((row) => ({ (await listMembershipsForUser(userId)).map((row) => ({
membership: normalizeWorkspaceMember(row), membership: normalizeWorkspaceMember(row),
@@ -1412,7 +1449,7 @@ const sendBirdMilestoneReminderNotification = async ({
return { delivered: true }; return { delivered: true };
}; };
const runBirdMilestoneReminders = async (runDate = getDateInTimeZone()) => { export const runBirdMilestoneReminders = async (runDate = getDateInTimeZone()) => {
const reminders = await listDueBirdMilestoneReminders(runDate); const reminders = await listDueBirdMilestoneReminders(runDate);
let sent = 0; let sent = 0;
let skipped = 0; let skipped = 0;
@@ -1458,7 +1495,7 @@ const runBirdMilestoneReminders = async (runDate = getDateInTimeZone()) => {
let lastMilestoneReminderRunDate = ''; let lastMilestoneReminderRunDate = '';
const startBirdMilestoneReminderScheduler = () => { export const startBirdMilestoneReminderScheduler = () => {
if (!milestoneRemindersEnabled) { if (!milestoneRemindersEnabled) {
console.log('Bird milestone reminders are disabled.'); console.log('Bird milestone reminders are disabled.');
return; return;
@@ -1471,10 +1508,8 @@ const startBirdMilestoneReminderScheduler = () => {
} }
lastMilestoneReminderRunDate = runDate; lastMilestoneReminderRunDate = runDate;
const result = await runBirdMilestoneReminders(runDate); const job = await enqueueBirdMilestoneReminderJob(runDate);
console.log( console.log(`Bird milestone reminder job queued for ${runDate}: id=${job.id ?? 'unknown'}`);
`Bird milestone reminders completed for ${result.runDate}: checked=${result.checked}, sent=${result.sent}, skipped=${result.skipped}, failed=${result.failed}`,
);
}; };
setTimeout(() => { setTimeout(() => {
@@ -1614,6 +1649,40 @@ app.get('/api/health', (_req: Request, res: Response) => {
res.json({ ok: true }); res.json({ ok: true });
}); });
app.get('/api/metrics', requireAuth, requireAdmin, async (_req: Request, res: Response, next: NextFunction) => {
const memoryUsage = process.memoryUsage();
const averageDurationMs = requestMetrics.totalRequests > 0 ? requestMetrics.totalDurationMs / requestMetrics.totalRequests : 0;
try {
const birdMilestoneReminderQueueCounts = await getBirdMilestoneReminderQueueCounts();
res.json({
startedAt: requestMetrics.startedAt,
uptimeSeconds: Math.round(process.uptime()),
requests: {
total: requestMetrics.totalRequests,
inFlight: requestMetrics.inFlightRequests,
errors: requestMetrics.totalErrors,
averageDurationMs: Number(averageDurationMs.toFixed(2)),
byStatus: requestMetrics.byStatus,
byRoute: requestMetrics.byRoute,
},
memory: {
rss: memoryUsage.rss,
heapTotal: memoryUsage.heapTotal,
heapUsed: memoryUsage.heapUsed,
external: memoryUsage.external,
arrayBuffers: memoryUsage.arrayBuffers,
},
queues: {
birdMilestoneReminders: birdMilestoneReminderQueueCounts,
},
});
} catch (error) {
next(error);
}
});
app.post('/api/lost-bird/report', lostBirdReportLimiter, async (req: Request, res: Response) => { app.post('/api/lost-bird/report', lostBirdReportLimiter, async (req: Request, res: Response) => {
const parsed = lostBirdReportSchema.safeParse(req.body); const parsed = lostBirdReportSchema.safeParse(req.body);
@@ -3073,15 +3142,18 @@ app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
res.status(500).json({ error: error instanceof Error ? error.message : 'Internal server error' }); res.status(500).json({ error: error instanceof Error ? error.message : 'Internal server error' });
}); });
const start = async () => { export const startApiServer = async () => {
await ensureSchema(); await ensureSchema();
app.listen(port, () => { app.listen(port, () => {
console.log(`FlockPal backend listening on port ${port}`); console.log(`FlockPal backend listening on port ${port}`);
}); });
startBirdMilestoneReminderScheduler();
}; };
start().catch((error) => { const currentModulePath = fileURLToPath(import.meta.url);
if (process.argv[1] === currentModulePath) {
startApiServer().catch((error) => {
console.error('Failed to start backend', error); console.error('Failed to start backend', error);
process.exit(1); process.exit(1);
}); });
}
+33
View File
@@ -47,6 +47,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ON workspaces (stripe_customer_id) ON workspaces (stripe_customer_id)
WHERE stripe_customer_id IS NOT NULL; WHERE stripe_customer_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_workspaces_rescue_status
ON workspaces (workspace_type, rescue_verification_status, created_at DESC);
UPDATE workspaces UPDATE workspaces
SET subscription_status = 'none' SET subscription_status = 'none'
WHERE workspace_type = 'standard' WHERE workspace_type = 'standard'
@@ -119,6 +122,15 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ON workspace_members (workspace_id, user_id) ON workspace_members (workspace_id, user_id)
WHERE user_id IS NOT NULL; WHERE user_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_workspace_members_user_accepted
ON workspace_members (user_id, accepted_at, workspace_id)
WHERE user_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_workspace_members_owner_email
ON workspace_members (LOWER(COALESCE(invite_email, email)), workspace_id)
WHERE role = 'owner'
AND accepted_at IS NOT NULL;
CREATE TABLE IF NOT EXISTS auth_sessions ( CREATE TABLE IF NOT EXISTS auth_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -128,6 +140,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
CREATE INDEX IF NOT EXISTS idx_auth_sessions_created_user
ON auth_sessions (created_at DESC, user_id);
CREATE TABLE IF NOT EXISTS integration_tokens ( CREATE TABLE IF NOT EXISTS integration_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -257,6 +272,21 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
AND BTRIM(tag_id) <> '' AND BTRIM(tag_id) <> ''
AND LOWER(BTRIM(tag_id)) NOT IN ('unknown', 'not recorded', 'n/a', 'na', 'none'); AND LOWER(BTRIM(tag_id)) NOT IN ('unknown', 'not recorded', 'n/a', 'na', 'none');
CREATE INDEX IF NOT EXISTS idx_birds_workspace_active_name
ON birds (workspace_id, name)
WHERE memorialized_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_birds_workspace_memorialized
ON birds (workspace_id, memorialized_on DESC, name)
WHERE memorialized_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_birds_tag_lookup_active
ON birds (LOWER(tag_id), created_at)
WHERE tag_id IS NOT NULL
AND BTRIM(tag_id) <> ''
AND LOWER(BTRIM(tag_id)) NOT IN ('unknown', 'not recorded', 'n/a', 'na', 'none')
AND memorialized_at IS NULL;
CREATE TABLE IF NOT EXISTS pending_bird_transfers ( CREATE TABLE IF NOT EXISTS pending_bird_transfers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE, bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
@@ -374,6 +404,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
CREATE INDEX IF NOT EXISTS idx_medication_administrations_bird_administered_on CREATE INDEX IF NOT EXISTS idx_medication_administrations_bird_administered_on
ON medication_administrations (bird_id, administered_on DESC); ON medication_administrations (bird_id, administered_on DESC);
CREATE INDEX IF NOT EXISTS idx_medication_administrations_medication_date
ON medication_administrations (medication_id, administered_on DESC, created_at DESC);
DO $$ DO $$
BEGIN BEGIN
IF EXISTS ( IF EXISTS (
@@ -0,0 +1,53 @@
import { Queue, type Job } from 'bullmq';
import { redisConnection } from './redisConnection.js';
export type BirdMilestoneReminderJobData = {
runDate: string;
requestedBy: 'scheduler';
};
export type BirdMilestoneReminderJobResult = {
runDate: string;
checked: number;
sent: number;
skipped: number;
failed: number;
};
export const birdMilestoneReminderQueueName = 'bird-milestone-reminders';
export const birdMilestoneReminderQueue = new Queue<BirdMilestoneReminderJobData, BirdMilestoneReminderJobResult>(
birdMilestoneReminderQueueName,
{
connection: redisConnection,
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 60_000,
},
removeOnComplete: 100,
removeOnFail: 1_000,
},
},
);
export const enqueueBirdMilestoneReminderJob = (runDate: string): Promise<Job<BirdMilestoneReminderJobData, BirdMilestoneReminderJobResult>> =>
birdMilestoneReminderQueue.add(
'run-daily-reminders',
{
runDate,
requestedBy: 'scheduler',
},
{
jobId: `bird-milestone-reminders:${runDate}`,
},
);
export const closeBirdMilestoneReminderQueue = async () => {
await birdMilestoneReminderQueue.close();
};
export const getBirdMilestoneReminderQueueCounts = () =>
birdMilestoneReminderQueue.getJobCounts('waiting', 'active', 'delayed', 'completed', 'failed');
+19
View File
@@ -0,0 +1,19 @@
import type { RedisOptions } from 'bullmq';
const redisUrl = process.env.REDIS_URL?.trim() || 'redis://localhost:6379';
const parseRedisConnection = (): RedisOptions => {
const url = new URL(redisUrl);
const db = url.pathname && url.pathname !== '/' ? Number(url.pathname.slice(1)) : undefined;
return {
host: url.hostname,
port: url.port ? Number(url.port) : 6379,
username: url.username ? decodeURIComponent(url.username) : undefined,
password: url.password ? decodeURIComponent(url.password) : undefined,
db: Number.isFinite(db) ? db : undefined,
maxRetriesPerRequest: null,
};
};
export const redisConnection = parseRedisConnection();
+61
View File
@@ -0,0 +1,61 @@
import { Worker } from 'bullmq';
import { ensureSchema } from './db/schema.js';
import { db } from './db/client.js';
import { runBirdMilestoneReminders, startBirdMilestoneReminderScheduler } from './app.js';
import {
birdMilestoneReminderQueueName,
closeBirdMilestoneReminderQueue,
type BirdMilestoneReminderJobData,
type BirdMilestoneReminderJobResult,
} from './queues/birdMilestoneReminderQueue.js';
import { redisConnection } from './queues/redisConnection.js';
let birdMilestoneWorker: Worker<BirdMilestoneReminderJobData, BirdMilestoneReminderJobResult> | null = null;
const startWorker = async () => {
await ensureSchema();
birdMilestoneWorker = new Worker<BirdMilestoneReminderJobData, BirdMilestoneReminderJobResult>(
birdMilestoneReminderQueueName,
async (job) => {
const result = await runBirdMilestoneReminders(job.data.runDate);
console.log(
`Bird milestone reminder job completed for ${result.runDate}: checked=${result.checked}, sent=${result.sent}, skipped=${result.skipped}, failed=${result.failed}`,
);
return result;
},
{
connection: redisConnection,
concurrency: 1,
},
);
birdMilestoneWorker.on('failed', (job, error) => {
console.error(`Bird milestone reminder job failed: id=${job?.id ?? 'unknown'}`, error);
});
startBirdMilestoneReminderScheduler();
console.log('FlockPal worker started.');
};
const shutdown = async (signal: string) => {
console.log(`FlockPal worker received ${signal}; shutting down.`);
await birdMilestoneWorker?.close();
await closeBirdMilestoneReminderQueue();
await db.close();
process.exit(0);
};
process.on('SIGINT', () => {
void shutdown('SIGINT');
});
process.on('SIGTERM', () => {
void shutdown('SIGTERM');
});
startWorker().catch((error) => {
console.error('Failed to start FlockPal worker', error);
process.exit(1);
});
+53
View File
@@ -15,6 +15,19 @@ services:
retries: 10 retries: 10
restart: unless-stopped restart: unless-stopped
redis:
image: redis:7-alpine
container_name: flockpal-redis
command: ["redis-server", "--appendonly", "yes"]
volumes:
- ./data/redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 10
restart: unless-stopped
backend: backend:
build: build:
context: ./backend context: ./backend
@@ -29,10 +42,12 @@ services:
POSTGRES_DB: ${POSTGRES_DB:-flockpal} POSTGRES_DB: ${POSTGRES_DB:-flockpal}
POSTGRES_USER: ${POSTGRES_USER:-flockpal} POSTGRES_USER: ${POSTGRES_USER:-flockpal}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD for production} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD for production}
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production} FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production}
BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production} BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production}
ADMIN_EMAILS: ${ADMIN_EMAILS:-} ADMIN_EMAILS: ${ADMIN_EMAILS:-}
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app} RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true} MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York} MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
@@ -65,6 +80,8 @@ services:
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
redis:
condition: service_healthy
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.docker.network=traefik - traefik.docker.network=traefik
@@ -79,6 +96,42 @@ services:
- traefik - traefik
restart: unless-stopped restart: unless-stopped
worker:
build:
context: ./backend
dockerfile: Dockerfile
container_name: flockpal-worker
command: ["npm", "run", "worker"]
environment:
NODE_ENV: production
TRUST_PROXY: ${TRUST_PROXY:-1}
POSTGRES_HOST: postgres
POSTGRES_PORT: 5432
POSTGRES_DB: ${POSTGRES_DB:-flockpal}
POSTGRES_USER: ${POSTGRES_USER:-flockpal}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD for production}
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production}
BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production}
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_SECURE: ${SMTP_SECURE:-false}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASS: ${SMTP_PASS:-}
SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-}
SMTP_FROM_NAME: ${SMTP_FROM_NAME:-FlockPal}
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
frontend: frontend:
build: build:
context: ./frontend context: ./frontend
+57
View File
@@ -14,6 +14,18 @@ services:
timeout: 5s timeout: 5s
retries: 10 retries: 10
redis:
image: redis:7-alpine
container_name: flockpal-redis
command: ["redis-server", "--appendonly", "yes"]
volumes:
- ./data/redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 10
backend: backend:
build: build:
context: ./backend context: ./backend
@@ -28,10 +40,12 @@ services:
POSTGRES_DB: ${POSTGRES_DB:-flockpal} POSTGRES_DB: ${POSTGRES_DB:-flockpal}
POSTGRES_USER: ${POSTGRES_USER:-flockpal} POSTGRES_USER: ${POSTGRES_USER:-flockpal}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flockpal_dev_password} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flockpal_dev_password}
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000} FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
BACKEND_URL: ${BACKEND_URL:-http://localhost:5000} BACKEND_URL: ${BACKEND_URL:-http://localhost:5000}
ADMIN_EMAILS: ${ADMIN_EMAILS:-} ADMIN_EMAILS: ${ADMIN_EMAILS:-}
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app} RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true} MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York} MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
@@ -64,6 +78,8 @@ services:
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
redis:
condition: service_healthy
ports: ports:
- "5000:5000" - "5000:5000"
command: > command: >
@@ -73,6 +89,47 @@ services:
- ./backend:/app - ./backend:/app
- /app/node_modules - /app/node_modules
worker:
build:
context: ./backend
dockerfile: Dockerfile.dev
container_name: flockpal-worker
environment:
PORT: 5000
NODE_ENV: development
TRUST_PROXY: ${TRUST_PROXY:-}
POSTGRES_HOST: postgres
POSTGRES_PORT: 5432
POSTGRES_DB: ${POSTGRES_DB:-flockpal}
POSTGRES_USER: ${POSTGRES_USER:-flockpal}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flockpal_dev_password}
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
BACKEND_URL: ${BACKEND_URL:-http://localhost:5000}
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_SECURE: ${SMTP_SECURE:-false}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASS: ${SMTP_PASS:-}
SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-}
SMTP_FROM_NAME: ${SMTP_FROM_NAME:-FlockPal}
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
command: >
sh -c "npm install --include=dev &&
npm run worker:dev"
volumes:
- ./backend:/app
- /app/node_modules
frontend: frontend:
build: build:
context: ./frontend context: ./frontend
+55
View File
@@ -323,6 +323,61 @@ Response `200`:
{ "ok": true } { "ok": true }
``` ```
### Metrics
#### `GET /api/metrics`
Requires auth and an admin user. Returns lightweight in-memory process and request counters for the current backend process.
Response `200`:
```json
{
"startedAt": "2026-05-01T00:00:00.000Z",
"uptimeSeconds": 120,
"requests": {
"total": 10,
"inFlight": 0,
"errors": 0,
"averageDurationMs": 12.5,
"byStatus": { "2xx": 10 },
"byRoute": { "GET /api/health": 10 }
},
"memory": {
"rss": 0,
"heapTotal": 0,
"heapUsed": 0,
"external": 0,
"arrayBuffers": 0
},
"queues": {
"birdMilestoneReminders": {
"waiting": 0,
"active": 0,
"delayed": 0,
"completed": 0,
"failed": 0
}
}
}
```
### Admin
#### `PATCH /api/admin/rescue-workspaces/:workspaceId`
Requires an admin user. Browser session tokens and admin-owned `read_write` integration tokens are accepted, so automation tools can approve or reject rescue claims after external validation.
Request body:
```json
{
"rescueVerificationStatus": "approved"
}
```
Allowed statuses are `pending`, `approved`, and `rejected`.
### Authentication ### Authentication
#### `GET /api/auth/providers` #### `GET /api/auth/providers`
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
compose_file="${COMPOSE_FILE:-docker-compose.prod.yml}"
backup_dir="${BACKUP_DIR:-backups/postgres}"
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
backup_path="${backup_dir}/flockpal-${timestamp}.dump"
mkdir -p "$backup_dir"
docker compose -f "$compose_file" exec -T postgres sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --format=custom --no-owner --no-privileges' > "$backup_path"
echo "$backup_path"
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 1 ]]; then
echo "Usage: $0 backups/postgres/flockpal-YYYYMMDDTHHMMSSZ.dump" >&2
exit 2
fi
backup_path="$1"
compose_file="${COMPOSE_FILE:-docker-compose.prod.yml}"
restore_db="flockpal_restore_test_$(date -u +%Y%m%d%H%M%S)"
if [[ ! -f "$backup_path" ]]; then
echo "Backup file not found: $backup_path" >&2
exit 2
fi
cleanup() {
docker compose -f "$compose_file" exec -T postgres sh -c "dropdb -U \"\$POSTGRES_USER\" --if-exists \"$restore_db\"" >/dev/null
}
trap cleanup EXIT
docker compose -f "$compose_file" exec -T postgres sh -c "createdb -U \"\$POSTGRES_USER\" \"$restore_db\""
docker compose -f "$compose_file" exec -T postgres sh -c "pg_restore -U \"\$POSTGRES_USER\" -d \"$restore_db\" --no-owner --no-privileges" < "$backup_path"
docker compose -f "$compose_file" exec -T postgres sh -c "psql -U \"\$POSTGRES_USER\" -d \"$restore_db\" -v ON_ERROR_STOP=1 -c 'SELECT COUNT(*) AS workspaces FROM workspaces;'"
echo "Restore test passed for $backup_path"