Added redis and worker services
This commit is contained in:
@@ -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=
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ frontend/node_modules
|
|||||||
backend/dist
|
backend/dist
|
||||||
frontend/dist
|
frontend/dist
|
||||||
data/
|
data/
|
||||||
|
backups/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Generated
+307
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -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();
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
Executable
+13
@@ -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"
|
||||||
Executable
+27
@@ -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"
|
||||||
Reference in New Issue
Block a user