67 Commits

Author SHA1 Message Date
Corey Blais 18fd76dc1f another fix of timeline fonts
Deploy / deploy-dev (push) Successful in 2m13s
Deploy / deploy-prod (push) Has been skipped
2026-07-01 21:05:23 -04:00
Corey Blais f627157a14 fixing timeline fonts in dev
Deploy / deploy-dev (push) Successful in 2m14s
Deploy / deploy-prod (push) Has been skipped
2026-07-01 18:16:43 -04:00
Corey Blais 35bd87b8b5 Updated timeline
Deploy / deploy-dev (push) Successful in 2m20s
Deploy / deploy-prod (push) Has been skipped
2026-07-01 17:52:40 -04:00
blaisadmin 14bc1c30a0 Merge origin/dev into dev
Deploy / deploy-dev (push) Successful in 3m48s
Deploy / deploy-prod (push) Has been skipped
2026-06-30 23:29:31 -04:00
blaisadmin d03672fcdd Fixed Timeline 2026-06-30 23:25:07 -04:00
Corey Blais 46e07336ef Fixing dev timeline icons
Deploy / deploy-dev (push) Successful in 2m12s
Deploy / deploy-prod (push) Has been skipped
2026-06-30 18:25:30 -04:00
Corey Blais bcaa8c4464 Fixing dev timeline icons
Deploy / deploy-dev (push) Successful in 2m15s
Deploy / deploy-prod (push) Has been skipped
2026-06-30 18:05:59 -04:00
Corey Blais 84d850a1ba Updated timeline
Deploy / deploy-dev (push) Successful in 2m42s
Deploy / deploy-prod (push) Has been skipped
2026-06-30 17:05:26 -04:00
Corey Blais 0dfacc0d17 Fixed timeline icon
Deploy / deploy-dev (push) Successful in 2m9s
Deploy / deploy-prod (push) Has been skipped
2026-06-30 12:16:31 -04:00
blaisadmin 7ef20ab0fb Added mapbox integration
Deploy / deploy-dev (push) Successful in 2m33s
Deploy / deploy-prod (push) Has been skipped
2026-06-29 19:50:08 -04:00
blaisadmin 9ddd85b5c4 working on timeline locations
Deploy / deploy-dev (push) Successful in 2m21s
Deploy / deploy-prod (push) Has been skipped
2026-06-28 22:57:22 -04:00
Corey Blais a988d9662b Added timeline feature
Deploy / deploy-dev (push) Successful in 2m42s
Deploy / deploy-prod (push) Has been skipped
2026-06-28 12:30:36 -04:00
blaisadmin 56068e02a3 Additional Genders
Deploy / deploy-dev (push) Successful in 3m17s
Deploy / deploy-prod (push) Has been skipped
2026-06-17 21:59:09 -04:00
Corey Blais 1140be8f32 weight edit fixes
Deploy / deploy-dev (push) Successful in 3m1s
Deploy / deploy-prod (push) Has been skipped
2026-06-16 15:28:01 -04:00
Corey Blais 4d3ab0b143 Updated weight edit
Deploy / deploy-dev (push) Successful in 2m1s
Deploy / deploy-prod (push) Has been skipped
2026-06-16 10:33:34 -04:00
Corey Blais 1e98d55cb5 trimmed weight edit
Deploy / deploy-dev (push) Successful in 2m42s
Deploy / deploy-prod (push) Has been skipped
2026-06-16 10:00:50 -04:00
Corey Blais 454adc6f5e adding weight edits
Deploy / deploy-dev (push) Successful in 3m0s
Deploy / deploy-prod (push) Has been skipped
2026-06-16 09:31:16 -04:00
blaisadmin ae8c4326b5 dev login fixes
Deploy / deploy-dev (push) Successful in 1m55s
Deploy / deploy-prod (push) Has been skipped
2026-06-05 22:12:19 -04:00
blaisadmin 480bbe8fc7 Adding promoting to owner
Deploy / deploy-dev (push) Successful in 2m26s
Deploy / deploy-prod (push) Has been skipped
2026-06-05 21:15:55 -04:00
blaisadmin bb589e3489 Adjusting role actions
Deploy / deploy-dev (push) Successful in 2m29s
Deploy / deploy-prod (push) Has been skipped
2026-06-05 21:09:31 -04:00
Corey Blais c3bec15c63 Medication reminder and pdr worker
Deploy / deploy-dev (push) Successful in 2m39s
Deploy / deploy-prod (push) Has been skipped
2026-06-03 15:21:21 -04:00
Corey Blais 979a17132d Fix adoption report photos and section order
Deploy / deploy-dev (push) Successful in 1m41s
Deploy / deploy-prod (push) Has been skipped
2026-06-02 18:26:51 -04:00
Corey Blais cadbdc2a7f Fix adoption report QR and chart layout
Deploy / deploy-dev (push) Successful in 1m40s
Deploy / deploy-prod (push) Has been skipped
2026-06-02 18:19:01 -04:00
Corey Blais 8e2f789e9b Refine adoption report header layout
Deploy / deploy-dev (push) Successful in 1m45s
Deploy / deploy-prod (push) Has been skipped
2026-06-02 18:10:13 -04:00
Corey Blais 7e2d06c50b Generate adoption reports as PDFs
Deploy / deploy-dev (push) Successful in 2m24s
Deploy / deploy-prod (push) Has been skipped
2026-06-02 17:49:31 -04:00
Corey Blais c2d518f864 Use fixed screen size for adoption report sheet
Deploy / deploy-dev (push) Successful in 1m32s
Deploy / deploy-prod (push) Has been skipped
2026-06-02 17:31:33 -04:00
Corey Blais c3297b5915 Wrap adoption report in letter sheet
Deploy / deploy-dev (push) Successful in 1m37s
Deploy / deploy-prod (push) Has been skipped
2026-06-02 17:24:32 -04:00
Corey Blais 41dda33310 Tighten adoption report page scale
Deploy / deploy-dev (push) Successful in 1m40s
Deploy / deploy-prod (push) Has been skipped
2026-06-02 17:17:21 -04:00
Corey Blais c98a5a2863 Size adoption report for letter paper
Deploy / deploy-dev (push) Successful in 1m38s
Deploy / deploy-prod (push) Has been skipped
2026-06-02 17:10:55 -04:00
Corey Blais 6c9017c3dc Stabilize adoption report layout
Deploy / deploy-dev (push) Successful in 1m41s
Deploy / deploy-prod (push) Has been skipped
2026-06-02 16:33:26 -04:00
Corey Blais 6b11a73579 fixing analytics icon pt2
Deploy / deploy-dev (push) Successful in 1m41s
Deploy / deploy-prod (push) Has been skipped
2026-06-02 12:28:34 -04:00
Corey Blais 1f26255ebd fixing analytics icon
Deploy / deploy-dev (push) Successful in 1m45s
Deploy / deploy-prod (push) Has been skipped
2026-06-02 12:22:14 -04:00
Corey Blais 14cdfe603d Updated adoption report
Deploy / deploy-dev (push) Successful in 2m0s
Deploy / deploy-prod (push) Has been skipped
2026-06-02 12:13:01 -04:00
Corey Blais d5bb87910e Added adoption report and transfer code
Deploy / deploy-dev (push) Successful in 2m31s
Deploy / deploy-prod (push) Has been skipped
2026-06-01 18:57:53 -04:00
Corey Blais 3053e3bef5 Added vet info
Deploy / deploy-dev (push) Successful in 3m0s
Deploy / deploy-prod (push) Has been skipped
2026-06-01 14:59:03 -04:00
blaisadmin 6ade13a8be Add flock member notes and audit tabs
Deploy / deploy-dev (push) Successful in 2m27s
Deploy / deploy-prod (push) Has been skipped
2026-05-30 22:30:35 -04:00
blaisadmin d2763744eb Fixed build test
Deploy / deploy-dev (push) Successful in 2m35s
Deploy / deploy-prod (push) Has been skipped
2026-05-30 15:22:43 -04:00
blaisadmin 841d0a9669 Updated subscriptions
Deploy / deploy-dev (push) Failing after 4s
Deploy / deploy-prod (push) Has been skipped
2026-05-30 15:19:47 -04:00
blaisadmin b4f6193395 add codegraph
Deploy / deploy-dev (push) Successful in 1m23s
Deploy / deploy-prod (push) Has been skipped
2026-05-30 12:28:16 -04:00
blaisadmin 9ee46e53e0 Validate builds before dev deploy
Deploy / deploy-dev (push) Successful in 1m29s
Deploy / deploy-prod (push) Has been skipped
2026-05-24 21:56:56 -04:00
blaisadmin 613b2c941c Use standalone deploy compose files
Deploy / deploy-dev (push) Successful in 10s
Deploy / deploy-prod (push) Has been skipped
2026-05-23 18:34:06 -04:00
blaisadmin 3ab3f48f19 Use environment compose files in deploy workflow
Deploy / deploy-dev (push) Successful in 1m25s
Deploy / deploy-prod (push) Has been skipped
2026-05-23 18:31:00 -04:00
blaisadmin d9822e6626 Use host deploy paths for job container mounts
Deploy / deploy-dev (push) Successful in 40s
Deploy / deploy-prod (push) Has been skipped
2026-05-23 18:27:00 -04:00
blaisadmin 9e92e1212a Avoid duplicate Docker socket mount in deploy workflow
Deploy / deploy-prod (push) Has been skipped
Deploy / deploy-dev (push) Failing after 13m0s
2026-05-23 18:00:30 -04:00
blaisadmin 96bc76ef0d Mount deploy paths in Gitea job containers
Deploy / deploy-dev (push) Failing after 1s
Deploy / deploy-prod (push) Has been skipped
2026-05-23 17:57:44 -04:00
blaisadmin fe4e69ceb5 Use local mounts for Gitea deploy workflow
Deploy / deploy-dev (push) Failing after 3s
Deploy / deploy-prod (push) Has been skipped
2026-05-23 17:51:33 -04:00
blaisadmin c09e7f63ce Force deploy workflow to use configured SSH key
Deploy / deploy-prod (push) Has been skipped
Deploy / deploy-dev (push) Failing after 3s
2026-05-23 16:47:42 -04:00
blaisadmin 306e3a8c85 Add Gitea deploy workflow
Deploy / deploy-dev (push) Failing after 1m6s
Deploy / deploy-prod (push) Has been skipped
2026-05-23 16:35:11 -04:00
blaisadmin a502966293 Adding educational components 2026-05-21 22:10:51 -04:00
blaisadmin b7186528c5 Updated bird profile view 2026-05-21 21:23:49 -04:00
blaisadmin 49d75f34be Moved edit bird profile to flock page rather than settings 2026-05-21 20:46:34 -04:00
blaisadmin df3fcbf885 automated dev db build 2026-05-21 17:27:57 -04:00
blaisadmin 4715306d14 improved rescue workflow 2026-05-21 13:30:28 -04:00
blaisadmin 62afc94f2f fixed adding new workspace at login 2026-05-21 13:10:34 -04:00
blaisadmin e6211d7f5e adjustments for dev env 2026-05-21 01:17:42 -04:00
blaisadmin cf3cd96384 Added excel import 2026-05-21 00:04:05 -04:00
blaisadmin 38dcb7f49b updated public profile view 2026-05-20 22:14:07 -04:00
blaisadmin 1c0d57299d added qr, cleaned up profile views, and added the critical alerts 2026-05-20 21:54:17 -04:00
blaisadmin f2c506ec16 Updated bird profile view 2026-05-20 17:38:16 -04:00
Corey Blais 7514c7c306 Merge branch 'main' of https://git.blaishome.online/blaisadmin/FlockPal
# Conflicts:
#	backend/src/repositories/birdRepository.ts
2026-05-20 17:15:00 -04:00
Corey Blais 0db90aab45 Fixed delete workflow and added additional profile info 2026-05-20 17:12:15 -04:00
blaisadmin 6dbe51410c additional image changes 2026-05-02 12:11:20 -04:00
blaisadmin ac1afc613f improved image handling 2026-05-02 12:06:41 -04:00
blaisadmin 01541c5f5c Fixing images 2026-05-02 11:50:31 -04:00
blaisadmin fc6d7c2762 Updating migration script 2026-05-02 10:35:11 -04:00
blaisadmin 22f344a998 fixed db scripts 2026-05-02 10:24:58 -04:00
blaisadmin 1bb3002baf Adding Wasabi 2026-05-02 01:17:43 -04:00
41 changed files with 12044 additions and 1141 deletions
+16
View File
@@ -0,0 +1,16 @@
# CodeGraph data files
# These are local to each machine and should not be committed
# Database
*.db
*.db-wal
*.db-shm
# Cache
cache/
# Logs
*.log
# Hook markers
.dirty
+16 -3
View File
@@ -2,9 +2,19 @@ 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 REDIS_URL=redis://redis:6379
IMAGE_STORAGE_PROVIDER=database
S3_ENDPOINT=
S3_REGION=
S3_BUCKET=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_PUBLIC_BASE_URL=
S3_KEY_PREFIX=bird-photos
PHOTO_DELIVERY_MODE=proxy
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
MAPBOX_ACCESS_TOKEN=
NODE_ENV=development NODE_ENV=development
TRUST_PROXY= TRUST_PROXY=
ADMIN_EMAILS=corey@blaishome.online ADMIN_EMAILS=corey@blaishome.online
@@ -20,9 +30,12 @@ STRIPE_PRICE_HOUSEHOLD_CONURE_YEARLY=
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK= STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK=
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY= STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY=
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY= STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY=
STRIPE_PRICE_HOUSEHOLD_MACAW= STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY=
STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY= STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_MONTHLY=
STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY= STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_YEARLY=
STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW=
STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_MONTHLY=
STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_YEARLY=
STRIPE_CHECKOUT_SUCCESS_URL=http://localhost:3000/?billing=success STRIPE_CHECKOUT_SUCCESS_URL=http://localhost:3000/?billing=success
STRIPE_CHECKOUT_CANCEL_URL=http://localhost:3000/?billing=cancelled STRIPE_CHECKOUT_CANCEL_URL=http://localhost:3000/?billing=cancelled
STRIPE_PORTAL_RETURN_URL=http://localhost:3000/?billing=portal STRIPE_PORTAL_RETURN_URL=http://localhost:3000/?billing=portal
+87
View File
@@ -0,0 +1,87 @@
name: Deploy
on:
push:
branches:
- dev
- develop
workflow_dispatch:
jobs:
deploy-dev:
if: ${{ github.event_name == 'push' }}
runs-on: ubuntu-latest
container:
volumes:
- /docker/FlockPal-dev:/docker/FlockPal-dev
steps:
- name: Update dev checkout
run: |
set -e
cd /docker/FlockPal-dev
git fetch --all --prune
git reset --hard "origin/${{ github.ref_name }}"
git clean -fd
- name: Validate backend
run: |
set -e
cd /docker/FlockPal-dev
docker run --rm -v "$PWD/backend:/src:ro" -w /tmp/app node:22-alpine sh -lc "cp -a /src/. /tmp/app && npm ci && npm run build && npm test"
- name: Validate frontend
run: |
set -e
cd /docker/FlockPal-dev
docker run --rm -v "$PWD/frontend:/src:ro" -w /tmp/app node:22-alpine sh -lc "cp -a /src/. /tmp/app && npm ci && npm run build"
- name: Validate dev compose config
run: |
set -e
cd /docker/FlockPal-dev
docker compose -f docker-compose.dev.yaml config --quiet
- name: Deploy dev
run: |
set -e
cd /docker/FlockPal-dev
docker compose -f docker-compose.dev.yaml up -d --build
deploy-prod:
if: ${{ github.event_name == 'workflow_dispatch' }}
runs-on: ubuntu-latest
container:
volumes:
- /docker/FlockPal:/docker/FlockPal
steps:
- name: Update prod checkout
run: |
set -e
cd /docker/FlockPal
git fetch --all --prune
git reset --hard "origin/${{ github.ref_name }}"
git clean -fd
- name: Validate backend
run: |
set -e
cd /docker/FlockPal
docker run --rm -v "$PWD/backend:/src:ro" -w /tmp/app node:22-alpine sh -lc "cp -a /src/. /tmp/app && npm ci && npm run build && npm test"
- name: Validate frontend
run: |
set -e
cd /docker/FlockPal
docker run --rm -v "$PWD/frontend:/src:ro" -w /tmp/app node:22-alpine sh -lc "cp -a /src/. /tmp/app && npm ci && npm run build"
- name: Validate prod compose config
run: |
set -e
cd /docker/FlockPal
docker compose -f docker-compose.prod.yml config --quiet
- name: Deploy prod
run: |
set -e
cd /docker/FlockPal
docker compose -f docker-compose.prod.yml up -d --build
+1
View File
@@ -7,3 +7,4 @@ frontend/dist
data/ data/
backups/ backups/
.DS_Store .DS_Store
docker-compose.dev.yaml
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

+43 -3
View File
@@ -86,7 +86,14 @@ curl -H "Authorization: Bearer <admin-token>" https://your-host/api/metrics
- `FRONTEND_URL` - `FRONTEND_URL`
- `BACKEND_URL` - `BACKEND_URL`
- `VITE_API_BASE_URL` - `VITE_API_BASE_URL`
- `MAPBOX_ACCESS_TOKEN`
- `REDIS_URL` - `REDIS_URL`
- `IMAGE_STORAGE_PROVIDER`
- `S3_ENDPOINT`
- `S3_REGION`
- `S3_BUCKET`
- `S3_ACCESS_KEY_ID`
- `S3_SECRET_ACCESS_KEY`
- `RESCUE_ONBOARDING_WEBHOOK_URL` - `RESCUE_ONBOARDING_WEBHOOK_URL`
2. Build and start the production stack: 2. Build and start the production stack:
@@ -104,6 +111,37 @@ Compose includes a Redis service at `redis://redis:6379` and passes that value t
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. 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.
## Image storage
FlockPal currently keeps bird photos in Postgres as `photo_data_url`. The schema also has S3 object metadata columns so image storage can move to Wasabi/S3 without changing the bird record contract.
Set these when Wasabi image storage is ready:
- `IMAGE_STORAGE_PROVIDER=s3`
- `S3_ENDPOINT=https://s3.<wasabi-region>.wasabisys.com`
- `S3_REGION=<wasabi-region>`
- `S3_BUCKET=<bucket-name>`
- `S3_ACCESS_KEY_ID=<access-key>`
- `S3_SECRET_ACCESS_KEY=<secret-key>`
- `S3_PUBLIC_BASE_URL=<optional CDN or public bucket base URL; leave blank for private signed URLs>`
- `S3_KEY_PREFIX=bird-photos`
- `PHOTO_DELIVERY_MODE=proxy`
Use a dedicated private bucket and access key for FlockPal images. Grant only the S3 permissions the app needs for that bucket. When `S3_PUBLIC_BASE_URL` is blank, FlockPal stores private object keys. `PHOTO_DELIVERY_MODE=proxy` streams images through the backend after validating the app photo token; `PHOTO_DELIVERY_MODE=redirect` validates the app token and redirects to a short-lived Wasabi signed URL.
Migrate existing Postgres-stored bird photos after deploying S3 image storage:
```bash
docker compose -f docker-compose.prod.yml exec backend npm run migrate:bird-photos-to-s3 -- --apply
```
Run a dry run first by omitting `--apply`. Use `--limit=10` to migrate a small batch, and `--keep-data-url` if you want to leave the original inline image in Postgres during an initial verification pass.
Bucket settings recommendation:
- Enable bucket versioning if you want rollback protection from accidental overwrites or deletes. Add a lifecycle policy once upload volume is known because every object version contributes to stored data.
- Do not enable Object Lock on the primary app image bucket unless there is a strict legal/compliance retention requirement. Object Lock must be enabled when creating the bucket, depends on versioning, and can make user-requested image deletion or replacement harder.
## Worker process ## 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. 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.
@@ -166,8 +204,10 @@ Set these when the Stripe Checkout, Customer Portal, and webhook flow is enabled
- `STRIPE_PRICE_HOUSEHOLD_CONURE_YEARLY` - `STRIPE_PRICE_HOUSEHOLD_CONURE_YEARLY`
- `STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY` or `STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK` - `STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY` or `STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK`
- `STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY` - `STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY`
- `STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY` or `STRIPE_PRICE_HOUSEHOLD_MACAW` - `STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_MONTHLY` or `STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY`
- `STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY` - `STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_YEARLY`
- `STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_MONTHLY` or `STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW`
- `STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_YEARLY`
- `STRIPE_CHECKOUT_SUCCESS_URL` - `STRIPE_CHECKOUT_SUCCESS_URL`
- `STRIPE_CHECKOUT_CANCEL_URL` - `STRIPE_CHECKOUT_CANCEL_URL`
- `STRIPE_PORTAL_RETURN_URL` - `STRIPE_PORTAL_RETURN_URL`
@@ -184,7 +224,7 @@ Recommended defaults:
- Enable the proration behavior you want in the Customer Portal configuration. FlockPal treats Stripe as the source of truth for upgrade timing and proration outcomes. - Enable the proration behavior you want in the Customer Portal configuration. FlockPal treats Stripe as the source of truth for upgrade timing and proration outcomes.
- Point the Stripe webhook endpoint at `https://your-backend-host/api/billing/stripe/webhook`. - Point the Stripe webhook endpoint at `https://your-backend-host/api/billing/stripe/webhook`.
- Subscribe the webhook endpoint to at least `checkout.session.completed`, `customer.subscription.created`, `customer.subscription.updated`, and `customer.subscription.deleted`. - Subscribe the webhook endpoint to at least `checkout.session.completed`, `customer.subscription.created`, `customer.subscription.updated`, and `customer.subscription.deleted`.
- Use one Stripe Price per plan and billing interval. FlockPal maps Stripe price IDs back to `household_basic`, `household_plus`, and `household_macaw`. - Use one Stripe Price per plan and billing interval. FlockPal maps Stripe price IDs back to `household_basic`, `household_plus`, `household_macaw`, and `household_hyacinth_macaw`.
- After Stripe redirects back to the app, FlockPal now performs a direct billing sync against Stripe and then refreshes the active session. Webhooks are still required so asynchronous subscription changes stay in sync later. - After Stripe redirects back to the app, FlockPal now performs a direct billing sync against Stripe and then refreshes the active session. Webhooks are still required so asynchronous subscription changes stay in sync later.
For local development with the Stripe CLI: For local development with the Stripe CLI:
Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

+1049 -1
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -8,6 +8,8 @@
"worker:dev": "tsx watch src/worker.ts", "worker:dev": "tsx watch src/worker.ts",
"build": "tsc", "build": "tsc",
"test": "tsx --test src/**/*.test.ts", "test": "tsx --test src/**/*.test.ts",
"migrate:bird-photos-to-s3": "node dist/scripts/migrateBirdPhotosToS3.js",
"migrate:bird-photos-to-s3:dev": "tsx src/scripts/migrateBirdPhotosToS3.ts",
"start": "node dist/app.js", "start": "node dist/app.js",
"worker": "node dist/worker.js" "worker": "node dist/worker.js"
}, },
@@ -21,7 +23,10 @@
"helmet": "8.1.0", "helmet": "8.1.0",
"morgan": "1.10.0", "morgan": "1.10.0",
"nodemailer": "^8.0.5", "nodemailer": "^8.0.5",
"pdfkit": "^0.18.0",
"pg": "8.13.1", "pg": "8.13.1",
"qrcode": "^1.5.4",
"sharp": "^0.34.5",
"stripe": "^22.0.2", "stripe": "^22.0.2",
"zod": "3.24.1" "zod": "3.24.1"
}, },
@@ -30,7 +35,9 @@
"@types/express": "4.17.21", "@types/express": "4.17.21",
"@types/morgan": "1.9.9", "@types/morgan": "1.9.9",
"@types/node": "22.10.2", "@types/node": "22.10.2",
"@types/pdfkit": "^0.17.6",
"@types/pg": "8.11.10", "@types/pg": "8.11.10",
"@types/qrcode": "^1.5.6",
"tsx": "4.19.2", "tsx": "4.19.2",
"typescript": "5.7.2" "typescript": "5.7.2"
} }
+1835 -42
View File
File diff suppressed because it is too large Load Diff
+182 -6
View File
@@ -30,6 +30,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ALTER TABLE workspaces ALTER TABLE workspaces
DROP CONSTRAINT IF EXISTS workspaces_id_check; DROP CONSTRAINT IF EXISTS workspaces_id_check;
ALTER TABLE users
ADD COLUMN IF NOT EXISTS education_opt_out BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE workspaces ALTER TABLE workspaces
ADD COLUMN IF NOT EXISTS billing_email VARCHAR(255), ADD COLUMN IF NOT EXISTS billing_email VARCHAR(255),
ADD COLUMN IF NOT EXISTS billing_plan VARCHAR(32) NOT NULL DEFAULT 'household_basic', ADD COLUMN IF NOT EXISTS billing_plan VARCHAR(32) NOT NULL DEFAULT 'household_basic',
@@ -61,10 +64,6 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
WHERE workspace_type = 'rescue' WHERE workspace_type = 'rescue'
AND rescue_verification_status = 'not_required'; AND rescue_verification_status = 'not_required';
INSERT INTO workspaces (id, name, workspace_type, billing_plan)
VALUES (1, 'My Flock', 'standard', 'household_basic')
ON CONFLICT (id) DO NOTHING;
CREATE TABLE IF NOT EXISTS workspace_members ( CREATE TABLE IF NOT EXISTS workspace_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
@@ -143,6 +142,37 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
CREATE INDEX IF NOT EXISTS idx_auth_sessions_created_user CREATE INDEX IF NOT EXISTS idx_auth_sessions_created_user
ON auth_sessions (created_at DESC, user_id); ON auth_sessions (created_at DESC, user_id);
CREATE TABLE IF NOT EXISTS daily_education (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
publish_date DATE NOT NULL UNIQUE,
fact TEXT NOT NULL,
quiz_questions JSONB NOT NULL DEFAULT '[]'::jsonb,
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE daily_education
ALTER COLUMN quiz_questions SET DEFAULT '[]'::jsonb;
CREATE INDEX IF NOT EXISTS idx_daily_education_publish_date
ON daily_education (publish_date DESC);
CREATE TABLE IF NOT EXISTS education_question_bank (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
prompt VARCHAR(500) NOT NULL,
options JSONB NOT NULL,
correct_answer_index INTEGER NOT NULL,
explanation VARCHAR(800),
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CHECK (correct_answer_index >= 0 AND correct_answer_index <= 3)
);
CREATE INDEX IF NOT EXISTS idx_education_question_bank_created
ON education_question_bank (created_at DESC);
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,
@@ -216,13 +246,27 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
name VARCHAR(120) NOT NULL, name VARCHAR(120) NOT NULL,
tag_id VARCHAR(80), tag_id VARCHAR(80),
species VARCHAR(120) NOT NULL, species VARCHAR(120) NOT NULL,
motivators VARCHAR(1000),
demotivators VARCHAR(1000),
favorite_snack VARCHAR(160),
location_label VARCHAR(160),
location_details JSONB,
vet_clinic_name VARCHAR(160),
vet_clinic_address VARCHAR(500),
vet_account_number VARCHAR(120),
vet_doctor_name VARCHAR(160),
gender VARCHAR(16) NOT NULL DEFAULT 'unknown', gender VARCHAR(16) NOT NULL DEFAULT 'unknown',
date_of_birth DATE, date_of_birth DATE,
gotcha_day DATE, gotcha_day DATE,
chart_color VARCHAR(7) NOT NULL DEFAULT '#cb3a35', chart_color VARCHAR(7) NOT NULL DEFAULT '#cb3a35',
photo_data_url TEXT, photo_data_url TEXT,
photo_object_key TEXT,
photo_content_type VARCHAR(80),
photo_updated_at TIMESTAMPTZ,
notify_on_dob BOOLEAN NOT NULL DEFAULT FALSE, notify_on_dob BOOLEAN NOT NULL DEFAULT FALSE,
notify_on_gotcha_day BOOLEAN NOT NULL DEFAULT FALSE, notify_on_gotcha_day BOOLEAN NOT NULL DEFAULT FALSE,
public_profile_code VARCHAR(32),
public_profile_enabled BOOLEAN NOT NULL DEFAULT FALSE,
memorialized_at TIMESTAMPTZ, memorialized_at TIMESTAMPTZ,
memorialized_on DATE, memorialized_on DATE,
memorial_note VARCHAR(1000), memorial_note VARCHAR(1000),
@@ -232,13 +276,27 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ALTER TABLE birds ALTER TABLE birds
ADD COLUMN IF NOT EXISTS workspace_id INTEGER NOT NULL DEFAULT 1, ADD COLUMN IF NOT EXISTS workspace_id INTEGER NOT NULL DEFAULT 1,
ADD COLUMN IF NOT EXISTS motivators VARCHAR(1000),
ADD COLUMN IF NOT EXISTS demotivators VARCHAR(1000),
ADD COLUMN IF NOT EXISTS favorite_snack VARCHAR(160),
ADD COLUMN IF NOT EXISTS location_label VARCHAR(160),
ADD COLUMN IF NOT EXISTS location_details JSONB,
ADD COLUMN IF NOT EXISTS vet_clinic_name VARCHAR(160),
ADD COLUMN IF NOT EXISTS vet_clinic_address VARCHAR(500),
ADD COLUMN IF NOT EXISTS vet_account_number VARCHAR(120),
ADD COLUMN IF NOT EXISTS vet_doctor_name VARCHAR(160),
ADD COLUMN IF NOT EXISTS gender VARCHAR(16) NOT NULL DEFAULT 'unknown', ADD COLUMN IF NOT EXISTS gender VARCHAR(16) NOT NULL DEFAULT 'unknown',
ADD COLUMN IF NOT EXISTS date_of_birth DATE, ADD COLUMN IF NOT EXISTS date_of_birth DATE,
ADD COLUMN IF NOT EXISTS gotcha_day DATE, ADD COLUMN IF NOT EXISTS gotcha_day DATE,
ADD COLUMN IF NOT EXISTS chart_color VARCHAR(7) NOT NULL DEFAULT '#cb3a35', ADD COLUMN IF NOT EXISTS chart_color VARCHAR(7) NOT NULL DEFAULT '#cb3a35',
ADD COLUMN IF NOT EXISTS photo_data_url TEXT, ADD COLUMN IF NOT EXISTS photo_data_url TEXT,
ADD COLUMN IF NOT EXISTS photo_object_key TEXT,
ADD COLUMN IF NOT EXISTS photo_content_type VARCHAR(80),
ADD COLUMN IF NOT EXISTS photo_updated_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS notify_on_dob BOOLEAN NOT NULL DEFAULT FALSE, ADD COLUMN IF NOT EXISTS notify_on_dob BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS notify_on_gotcha_day BOOLEAN NOT NULL DEFAULT FALSE, ADD COLUMN IF NOT EXISTS notify_on_gotcha_day BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS public_profile_code VARCHAR(32),
ADD COLUMN IF NOT EXISTS public_profile_enabled BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS memorialized_at TIMESTAMPTZ, ADD COLUMN IF NOT EXISTS memorialized_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS memorialized_on DATE, ADD COLUMN IF NOT EXISTS memorialized_on DATE,
ADD COLUMN IF NOT EXISTS memorial_note VARCHAR(1000), ADD COLUMN IF NOT EXISTS memorial_note VARCHAR(1000),
@@ -261,13 +319,19 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
END IF; END IF;
END $$; END $$;
DELETE FROM workspaces
WHERE id = 1
AND name = 'My Flock'
AND NOT EXISTS (SELECT 1 FROM workspace_members WHERE workspace_members.workspace_id = workspaces.id)
AND NOT EXISTS (SELECT 1 FROM birds WHERE birds.workspace_id = workspaces.id);
ALTER TABLE birds ALTER TABLE birds
DROP CONSTRAINT IF EXISTS birds_tag_id_key; DROP CONSTRAINT IF EXISTS birds_tag_id_key;
DROP INDEX IF EXISTS idx_birds_workspace_tag_id; DROP INDEX IF EXISTS idx_birds_workspace_tag_id;
CREATE UNIQUE INDEX IF NOT EXISTS idx_birds_workspace_tag_id CREATE UNIQUE INDEX IF NOT EXISTS idx_birds_global_tag_id
ON birds (workspace_id, LOWER(tag_id)) ON birds (LOWER(BTRIM(tag_id)))
WHERE tag_id IS NOT NULL WHERE tag_id IS NOT NULL
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');
@@ -287,6 +351,14 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
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')
AND memorialized_at IS NULL; AND memorialized_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_birds_photo_object_key
ON birds (photo_object_key)
WHERE photo_object_key IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_birds_public_profile_code
ON birds (public_profile_code)
WHERE public_profile_code IS NOT 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,
@@ -312,6 +384,92 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ON pending_bird_transfers (bird_id) ON pending_bird_transfers (bird_id)
WHERE completed_at IS NULL; WHERE completed_at IS NULL;
CREATE TABLE IF NOT EXISTS bird_transfer_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(32) NOT NULL UNIQUE,
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
source_workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
requested_by_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
completed_at TIMESTAMPTZ,
completed_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_bird_transfer_codes_open_bird
ON bird_transfer_codes (bird_id, created_at DESC)
WHERE completed_at IS NULL
AND revoked_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_bird_transfer_codes_code_open
ON bird_transfer_codes (code)
WHERE completed_at IS NULL
AND revoked_at IS NULL;
CREATE TABLE IF NOT EXISTS bird_timeline_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
event_type VARCHAR(40) NOT NULL,
from_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL,
to_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL,
from_workspace_name VARCHAR(160),
to_workspace_name VARCHAR(160),
from_owner_email VARCHAR(255),
to_owner_email VARCHAR(255),
location_label VARCHAR(160),
location_details JSONB,
note TEXT,
event_date DATE NOT NULL DEFAULT CURRENT_DATE,
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE bird_timeline_events
ADD COLUMN IF NOT EXISTS note TEXT,
ADD COLUMN IF NOT EXISTS location_details JSONB,
ADD COLUMN IF NOT EXISTS event_date DATE NOT NULL DEFAULT CURRENT_DATE;
CREATE INDEX IF NOT EXISTS idx_bird_timeline_events_bird_created
ON bird_timeline_events (bird_id, event_date DESC, created_at DESC);
CREATE TABLE IF NOT EXISTS flock_notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
bird_id UUID REFERENCES birds(id) ON DELETE SET NULL,
title VARCHAR(160) NOT NULL,
body TEXT NOT NULL,
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_flock_notes_workspace_updated
ON flock_notes (workspace_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_flock_notes_bird_updated
ON flock_notes (bird_id, updated_at DESC)
WHERE bird_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS audit_log_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
actor_name VARCHAR(160),
actor_email VARCHAR(255),
action VARCHAR(80) NOT NULL,
entity_type VARCHAR(80) NOT NULL,
entity_id VARCHAR(120),
entity_name VARCHAR(255),
details JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_audit_log_entries_workspace_created
ON audit_log_entries (workspace_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_entries_entity
ON audit_log_entries (workspace_id, entity_type, entity_id, created_at DESC);
CREATE TABLE IF NOT EXISTS weight_records ( CREATE TABLE IF NOT EXISTS weight_records (
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,
@@ -343,6 +501,7 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
start_date DATE NOT NULL, start_date DATE NOT NULL,
end_date DATE, end_date DATE,
notes VARCHAR(1000), notes VARCHAR(1000),
reminders_enabled BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CHECK (end_date IS NULL OR end_date >= start_date) CHECK (end_date IS NULL OR end_date >= start_date)
); );
@@ -350,6 +509,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ALTER TABLE medications ALTER TABLE medications
ADD COLUMN IF NOT EXISTS dose_schedule JSONB NOT NULL DEFAULT '[{"key":"dose-1","label":"Dose","time":""}]'::jsonb; ADD COLUMN IF NOT EXISTS dose_schedule JSONB NOT NULL DEFAULT '[{"key":"dose-1","label":"Dose","time":""}]'::jsonb;
ALTER TABLE medications
ADD COLUMN IF NOT EXISTS reminders_enabled BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE IF NOT EXISTS bird_milestone_reminder_deliveries ( CREATE TABLE IF NOT EXISTS bird_milestone_reminder_deliveries (
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,
@@ -383,6 +545,17 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ALTER TABLE medication_administrations ALTER TABLE medication_administrations
ADD COLUMN IF NOT EXISTS administration_slot VARCHAR(80) NOT NULL DEFAULT 'dose-1'; ADD COLUMN IF NOT EXISTS administration_slot VARCHAR(80) NOT NULL DEFAULT 'dose-1';
CREATE TABLE IF NOT EXISTS medication_reminder_deliveries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
medication_id UUID NOT NULL REFERENCES medications(id) ON DELETE CASCADE,
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
scheduled_on DATE NOT NULL,
administration_slot VARCHAR(80) NOT NULL,
delivered_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (medication_id, scheduled_on, administration_slot)
);
ALTER TABLE medication_administrations ALTER TABLE medication_administrations
DROP CONSTRAINT IF EXISTS medication_administrations_medication_id_administered_on_key; DROP CONSTRAINT IF EXISTS medication_administrations_medication_id_administered_on_key;
@@ -401,6 +574,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
CREATE INDEX IF NOT EXISTS idx_bird_milestone_reminder_deliveries_workspace CREATE INDEX IF NOT EXISTS idx_bird_milestone_reminder_deliveries_workspace
ON bird_milestone_reminder_deliveries (workspace_id, delivered_on DESC); ON bird_milestone_reminder_deliveries (workspace_id, delivered_on DESC);
CREATE INDEX IF NOT EXISTS idx_medication_reminder_deliveries_workspace
ON medication_reminder_deliveries (workspace_id, scheduled_on DESC);
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);
+42
View File
@@ -0,0 +1,42 @@
import { Queue, QueueEvents, type Job } from 'bullmq';
import { redisConnection } from './redisConnection.js';
export type AdoptionReportJobData = {
birdId: string;
workspaceId: number;
transferCode: string;
printFriendly: boolean;
};
export type AdoptionReportJobResult = {
pdfBase64: string;
};
export const adoptionReportQueueName = 'adoption-reports';
export const adoptionReportQueue = new Queue<AdoptionReportJobData, AdoptionReportJobResult>(adoptionReportQueueName, {
connection: redisConnection,
defaultJobOptions: {
attempts: 2,
backoff: {
type: 'exponential',
delay: 10_000,
},
removeOnComplete: 50,
removeOnFail: 500,
},
});
export const adoptionReportQueueEvents = new QueueEvents(adoptionReportQueueName, {
connection: redisConnection,
});
export const enqueueAdoptionReportJob = (
data: AdoptionReportJobData,
): Promise<Job<AdoptionReportJobData, AdoptionReportJobResult>> => adoptionReportQueue.add('render-adoption-report', data);
export const closeAdoptionReportQueue = async () => {
await adoptionReportQueue.close();
await adoptionReportQueueEvents.close();
};
@@ -41,7 +41,7 @@ export const enqueueBirdMilestoneReminderJob = (runDate: string): Promise<Job<Bi
requestedBy: 'scheduler', requestedBy: 'scheduler',
}, },
{ {
jobId: `bird-milestone-reminders:${runDate}`, jobId: `bird-milestone-reminders-${runDate}`,
}, },
); );
@@ -0,0 +1,55 @@
import { Queue, type Job } from 'bullmq';
import { redisConnection } from './redisConnection.js';
export type MedicationReminderJobData = {
runDate: string;
currentTime: string;
requestedBy: 'scheduler';
};
export type MedicationReminderJobResult = {
runDate: string;
currentTime: string;
checked: number;
sent: number;
skipped: number;
failed: number;
};
export const medicationReminderQueueName = 'medication-reminders';
export const medicationReminderQueue = new Queue<MedicationReminderJobData, MedicationReminderJobResult>(medicationReminderQueueName, {
connection: redisConnection,
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 60_000,
},
removeOnComplete: 100,
removeOnFail: 1_000,
},
});
export const enqueueMedicationReminderJob = (
runDate: string,
currentTime: string,
): Promise<Job<MedicationReminderJobData, MedicationReminderJobResult>> =>
medicationReminderQueue.add(
'run-medication-reminders',
{
runDate,
currentTime,
requestedBy: 'scheduler',
},
{
jobId: `medication-reminders-${runDate}-${currentTime.slice(0, 2)}`,
},
);
export const closeMedicationReminderQueue = async () => {
await medicationReminderQueue.close();
};
export const getMedicationReminderQueueCounts = () => medicationReminderQueue.getJobCounts('waiting', 'active', 'delayed', 'completed', 'failed');
+422
View File
@@ -0,0 +1,422 @@
import fs from 'fs';
import PDFDocument from 'pdfkit';
import QRCode from 'qrcode';
import type { BirdRow, FlockNoteRow, VetVisitRow, WeightRow } from '../types.js';
type AdoptionReportInput = {
bird: BirdRow;
weights: WeightRow[];
vetVisits: VetVisitRow[];
notes: FlockNoteRow[];
transferCode: string;
birdPhotoBuffer?: Buffer | null;
assets: {
logoPath: string;
wordmarkPath: string;
defaultBirdPhotoPath: string;
};
printFriendly?: boolean;
};
const page = { width: 612, height: 792, margin: 42 };
const colors = {
ink: '#1f2a2a',
muted: '#5d5f59',
red: '#cb3a35',
green: '#238a5a',
blue: '#2769b3',
border: '#cfe0d5',
panel: '#fbf7ee',
paper: '#fffdf9',
};
const formatDate = (value: string | null) => {
if (!value) {
return 'Not recorded';
}
return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }).format(
new Date(`${value.slice(0, 10)}T00:00:00Z`),
);
};
const formatDateTime = (value: string | null) => {
if (!value) {
return 'Not recorded';
}
return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(new Date(value));
};
const formatShortDate = (value: string | null) => {
if (!value) {
return 'No data yet';
}
return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' }).format(new Date(`${value.slice(0, 10)}T00:00:00Z`));
};
const formatWeight = (value: string | number | null) => {
const numericValue = value === null ? null : Number(value);
return numericValue && Number.isFinite(numericValue) ? `${numericValue.toFixed(1)} g` : 'Pending';
};
const genderLabel = (value: string) => {
if (value === 'female_dna') {
return 'Female (DNA confirmed)';
}
if (value === 'male_dna') {
return 'Male (DNA confirmed)';
}
if (value === 'female') {
return 'Female (assumed)';
}
if (value === 'male') {
return 'Male (assumed)';
}
return 'Unknown';
};
const parseList = (value: string | null) =>
(value ?? '')
.split(/\r?\n|,/)
.map((entry) => entry.trim())
.filter(Boolean);
const dataUrlToBuffer = (value: string | null) => {
if (!value) {
return null;
}
const match = value.match(/^data:image\/(?:png|jpeg|jpg);base64,(.+)$/);
return match ? Buffer.from(match[1], 'base64') : null;
};
const collectPdf = (doc: PDFKit.PDFDocument) =>
new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = [];
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
});
const fitText = (doc: PDFKit.PDFDocument, text: string, x: number, y: number, width: number, options: PDFKit.Mixins.TextOptions = {}) => {
doc.text(text, x, y, { width, lineGap: 1.5, ...options });
return doc.y;
};
const drawFact = (doc: PDFKit.PDFDocument, label: string, value: string, x: number, y: number, width: number) => {
doc.roundedRect(x, y, width, 43, 6).fillAndStroke(colors.panel, colors.border);
doc.fillColor(colors.muted).fontSize(7).font('Helvetica-Bold').text(label.toUpperCase(), x + 8, y + 8, { width: width - 16 });
doc.fillColor(colors.ink).fontSize(10).font('Helvetica-Bold').text(value, x + 8, y + 21, { width: width - 16, ellipsis: true });
};
const drawTextCard = (doc: PDFKit.PDFDocument, label: string, value: string, x: number, y: number, width: number, height = 58) => {
doc.roundedRect(x, y, width, height, 6).fillAndStroke(colors.panel, colors.border);
doc.fillColor(colors.blue).fontSize(8).font('Helvetica-Bold').text(label.toUpperCase(), x + 8, y + 8, { width: width - 16 });
doc.fillColor(colors.ink).fontSize(9.2).font('Helvetica').text(value, x + 8, y + 23, {
width: width - 16,
height: height - 31,
ellipsis: true,
lineGap: 1.2,
});
};
const drawSectionTitle = (doc: PDFKit.PDFDocument, title: string, y: number) => {
doc.fillColor(colors.green).font('Helvetica-Bold').fontSize(14).text(title, page.margin, y);
doc.moveTo(page.margin, y + 19).lineTo(page.width - page.margin, y + 19).strokeColor(colors.border).lineWidth(1).stroke();
return y + 27;
};
const drawSimpleWeightChart = (doc: PDFKit.PDFDocument, weights: WeightRow[], birdColor: string, x: number, y: number, width: number, height: number) => {
const plottedWeights = weights
.slice()
.sort((left, right) => left.recorded_on.localeCompare(right.recorded_on))
.map((entry) => ({ ...entry, numericWeight: Number(entry.weight_grams) }))
.filter((entry) => Number.isFinite(entry.numericWeight));
doc.roundedRect(x, y, width, height, 8).fillAndStroke('#fffdf9', colors.border);
if (!plottedWeights.length) {
doc.fillColor(colors.muted).fontSize(10).text('Add more weight records to show a trend graph.', x + 14, y + height / 2 - 6, {
width: width - 28,
align: 'center',
});
return;
}
const latestDate = new Date(`${plottedWeights[plottedWeights.length - 1].recorded_on.slice(0, 10)}T00:00:00Z`);
const startDate = new Date(latestDate);
startDate.setUTCDate(startDate.getUTCDate() - 29);
const visibleWeights = plottedWeights.filter((entry) => {
const recordedOn = new Date(`${entry.recorded_on.slice(0, 10)}T00:00:00Z`);
return recordedOn >= startDate && recordedOn <= latestDate;
});
const rawMinWeight = Math.min(...visibleWeights.map((entry) => entry.numericWeight));
const rawMaxWeight = Math.max(...visibleWeights.map((entry) => entry.numericWeight));
const rangePadding = Math.max((rawMaxWeight - rawMinWeight) * 0.12, 2);
const minWeight = Math.max(0, rawMinWeight - rangePadding);
const maxWeight = rawMaxWeight + rangePadding;
const weightRange = Math.max(1, maxWeight - minWeight);
const padding = { top: 16, right: 18, bottom: 32, left: 48 };
const plotWidth = width - padding.left - padding.right;
const plotHeight = height - padding.top - padding.bottom;
const startMs = startDate.getTime();
const endMs = latestDate.getTime();
const dateRange = Math.max(endMs - startMs, 24 * 60 * 60 * 1000);
const chartColor = /^#[0-9a-fA-F]{6}$/.test(birdColor) ? birdColor : colors.green;
const midWeight = minWeight + (maxWeight - minWeight) / 2;
const midDate = new Date((startMs + endMs) / 2);
const yTicks = [
{ label: `${maxWeight.toFixed(0)} g`, y: y + padding.top },
{ label: `${midWeight.toFixed(0)} g`, y: y + padding.top + plotHeight / 2 },
{ label: `${minWeight.toFixed(0)} g`, y: y + padding.top + plotHeight },
];
const xTicks = [
{ label: formatShortDate(startDate.toISOString().slice(0, 10)), x: x + padding.left },
{ label: formatShortDate(midDate.toISOString().slice(0, 10)), x: x + padding.left + plotWidth / 2 },
{ label: formatShortDate(latestDate.toISOString().slice(0, 10)), x: x + padding.left + plotWidth },
];
const points = visibleWeights.map((entry) => {
const recordedOn = new Date(`${entry.recorded_on.slice(0, 10)}T00:00:00Z`);
return {
...entry,
x: x + padding.left + ((recordedOn.getTime() - startMs) / dateRange) * plotWidth,
y: y + padding.top + (1 - (entry.numericWeight - minWeight) / weightRange) * plotHeight,
};
});
doc.font('Helvetica').fontSize(7).fillColor(colors.muted);
yTicks.forEach((tick) => {
doc.text(tick.label, x + 4, tick.y - 3, { width: padding.left - 12, align: 'right' });
doc
.save()
.dash(4, { space: 6 })
.strokeColor('#d8e5ef')
.lineWidth(0.8)
.moveTo(x + padding.left, tick.y)
.lineTo(x + width - padding.right, tick.y)
.stroke()
.restore();
});
doc.strokeColor('#c7cdca').lineWidth(1).moveTo(x + padding.left, y + padding.top + plotHeight).lineTo(x + width - padding.right, y + padding.top + plotHeight).stroke();
xTicks.forEach((tick) => {
doc.fillColor(colors.muted).fontSize(7).text(tick.label, tick.x - 28, y + height - 18, { width: 56, align: 'center' });
});
points.forEach((entry, index) => {
if (index === 0) {
doc.moveTo(entry.x, entry.y);
} else {
doc.lineTo(entry.x, entry.y);
}
});
if (points.length > 1) {
doc.lineCap('round').strokeColor(chartColor).lineWidth(2.4).stroke();
}
points.forEach((entry) => {
doc.circle(entry.x, entry.y, 3.5).fillAndStroke(chartColor, '#fffdf9');
});
const latestPoint = points[points.length - 1];
const calloutOnLeft = latestPoint.x > x + width - padding.right - 84;
const calloutX = calloutOnLeft ? latestPoint.x - 82 : latestPoint.x + 8;
const calloutY = latestPoint.y < y + padding.top + 18 ? latestPoint.y + 8 : latestPoint.y - 22;
doc.roundedRect(calloutX, calloutY, 74, 18, 5).fillAndStroke('#fffdf9', '#d9dedb');
doc.fillColor(colors.ink).font('Helvetica-Bold').fontSize(7.5).text(`Latest ${formatWeight(latestPoint.numericWeight)}`, calloutX + 5, calloutY + 5, {
width: 64,
align: 'center',
});
};
const drawTable = (doc: PDFKit.PDFDocument, headers: string[], rows: string[][], x: number, y: number, widths: number[], rowHeight = 28) => {
doc.font('Helvetica-Bold').fontSize(8).fillColor(colors.muted);
headers.forEach((header, index) => {
doc.text(header.toUpperCase(), x + widths.slice(0, index).reduce((sum, value) => sum + value, 0), y, { width: widths[index] - 8 });
});
y += 15;
doc.moveTo(x, y - 4).lineTo(x + widths.reduce((sum, value) => sum + value, 0), y - 4).strokeColor(colors.border).stroke();
doc.font('Helvetica').fontSize(8.5).fillColor(colors.ink);
rows.forEach((row) => {
if (y + rowHeight > page.height - page.margin) {
doc.addPage();
y = page.margin;
}
row.forEach((value, index) => {
doc.text(value, x + widths.slice(0, index).reduce((sum, columnWidth) => sum + columnWidth, 0), y, {
width: widths[index] - 8,
height: rowHeight - 6,
ellipsis: true,
});
});
y += rowHeight;
doc.moveTo(x, y - 4).lineTo(x + widths.reduce((sum, value) => sum + value, 0), y - 4).strokeColor(colors.border).stroke();
});
return y + 6;
};
export const renderAdoptionReportPdf = async ({
bird,
weights,
vetVisits,
notes,
transferCode,
birdPhotoBuffer = null,
assets,
printFriendly = false,
}: AdoptionReportInput) => {
const doc = new PDFDocument({
size: 'LETTER',
margin: page.margin,
info: { Title: `FlockPal Adoption Report - ${bird.name}`, Author: 'FlockPal', Subject: `Adoption report for ${bird.name}` },
});
const output = collectPdf(doc);
if (!printFriendly) {
doc.rect(0, 0, page.width, page.height).fill(colors.paper);
}
const logoPath = fs.existsSync(assets.logoPath) ? assets.logoPath : null;
const wordmarkPath = fs.existsSync(assets.wordmarkPath) ? assets.wordmarkPath : logoPath;
const defaultPhotoPath = fs.existsSync(assets.defaultBirdPhotoPath) ? assets.defaultBirdPhotoPath : null;
const photoBuffer = birdPhotoBuffer ?? dataUrlToBuffer(bird.photo_data_url);
const contentWidth = page.width - page.margin * 2;
const headerY = page.margin;
const headerHeight = 136;
doc.roundedRect(page.margin, headerY, contentWidth, headerHeight, 12).fillAndStroke(printFriendly ? '#ffffff' : '#f8f4e8', colors.border);
if (logoPath) {
doc.image(logoPath, page.margin + 10, headerY + 18, { fit: [92, 84], align: 'center', valign: 'center' });
}
const photoX = page.margin + 235;
const photoY = headerY + 13;
if (photoBuffer) {
doc.image(photoBuffer, photoX, photoY, { fit: [58, 58], align: 'center', valign: 'center' });
} else if (defaultPhotoPath) {
doc.image(defaultPhotoPath, photoX, photoY, { fit: [58, 58], align: 'center', valign: 'center' });
}
doc.roundedRect(photoX, photoY, 58, 58, 10).strokeColor('#ffffff').lineWidth(2).stroke();
doc.fillColor(colors.red).font('Helvetica-Bold').fontSize(22).text(bird.name, page.margin + 140, headerY + 75, { width: 250, align: 'center' });
doc.fillColor(colors.muted).font('Helvetica').fontSize(9).text('Adoption Report', page.margin + 140, headerY + 98, { width: 250, align: 'center' });
const qrDataUrl = await QRCode.toDataURL(transferCode, { margin: 1, width: 96, errorCorrectionLevel: 'H' });
const qrBuffer = dataUrlToBuffer(qrDataUrl);
const qrX = page.width - page.margin - 132;
const qrWidth = 124;
doc.fillColor(colors.green).font('Helvetica-Bold').fontSize(8).text('JOIN', qrX, headerY + 7, { width: qrWidth, align: 'center' });
if (wordmarkPath) {
doc.image(wordmarkPath, qrX + 7, headerY + 18, { fit: [110, 34], align: 'center', valign: 'center' });
}
doc.fillColor(colors.red).font('Helvetica-Bold').fontSize(7.5).text('Keep my story growing', qrX, headerY + 51, {
width: qrWidth,
align: 'center',
});
if (qrBuffer) {
doc.image(qrBuffer, qrX + 37, headerY + 62, { width: 50 });
}
doc.fillColor(colors.green).font('Helvetica-Bold').fontSize(6.8).text('Scan to continue tracking in FlockPal', qrX, headerY + 114, {
width: qrWidth,
align: 'center',
});
doc.fillColor(colors.ink).font('Helvetica').fontSize(6.5).text(transferCode, qrX, headerY + 126, { width: qrWidth, align: 'center' });
let y = headerY + headerHeight + 16;
const factGap = 8;
const factWidth = (contentWidth - factGap) / 2;
const facts = [
['Species', bird.species],
['Band/tag ID', bird.tag_id || 'Not recorded'],
['Sex', genderLabel(bird.gender)],
['Hatch day', formatDate(bird.date_of_birth)],
['Favorite snack', bird.favorite_snack || 'Not recorded'],
['Latest weight', bird.latest_weight_grams ? `${formatWeight(bird.latest_weight_grams)}${bird.latest_recorded_on ? ` on ${formatDate(bird.latest_recorded_on)}` : ''}` : 'Pending'],
];
facts.forEach(([label, value], index) => {
drawFact(doc, label, value, page.margin + (index % 2) * (factWidth + factGap), y + Math.floor(index / 2) * 50, factWidth);
});
y += Math.ceil(facts.length / 2) * 50 + 8;
const motivators = parseList(bird.motivators);
const demotivators = parseList(bird.demotivators);
drawTextCard(doc, 'Motivators', motivators.length ? motivators.join(', ') : 'Not recorded', page.margin, y, factWidth);
drawTextCard(
doc,
'Demotivators',
demotivators.length ? demotivators.join(', ') : 'Not recorded',
page.margin + factWidth + factGap,
y,
factWidth,
);
y += 72;
if (y > 610) {
doc.addPage();
y = page.margin;
}
y = drawSectionTitle(doc, 'Veterinary Clinic Info', y);
const vetFacts = [
['Clinic name', bird.vet_clinic_name || 'Not recorded'],
['Clinic address', bird.vet_clinic_address || 'Not recorded'],
['Account #', bird.vet_account_number || 'Not recorded'],
['Dr. name', bird.vet_doctor_name || 'Not recorded'],
];
vetFacts.forEach(([label, value], index) => {
drawFact(doc, label, value, page.margin + (index % 2) * (factWidth + factGap), y + Math.floor(index / 2) * 50, factWidth);
});
y += Math.ceil(vetFacts.length / 2) * 50 + 8;
y = drawSectionTitle(doc, 'Vet Visit History', y);
y = drawTable(
doc,
['Date', 'Clinic', 'Reason', 'Notes'],
vetVisits.length ? vetVisits.map((visit) => [formatDate(visit.visited_on), visit.clinic_name, visit.reason, visit.notes || '']) : [['No vet visits recorded.', '', '', '']],
page.margin,
y,
[70, 115, 120, contentWidth - 305],
28,
);
if (y > 575) {
doc.addPage();
y = page.margin;
}
y = drawSectionTitle(doc, 'Weight Graph', y);
drawSimpleWeightChart(doc, weights, bird.chart_color, page.margin, y, contentWidth, 120);
y += 140;
y = drawSectionTitle(doc, 'Weight History', y);
y = drawTable(
doc,
['Date', 'Weight', 'Notes'],
weights.length ? weights.map((entry) => [formatDate(entry.recorded_on), formatWeight(entry.weight_grams), entry.notes || '']) : [['No weights recorded.', '', '']],
page.margin,
y,
[95, 70, contentWidth - 165],
24,
);
if (notes.length) {
if (y > 635) {
doc.addPage();
y = page.margin;
}
y = drawSectionTitle(doc, 'Notes', y);
notes.slice(0, 8).forEach((note) => {
if (y > page.height - page.margin - 48) {
doc.addPage();
y = page.margin;
}
doc.fillColor(colors.muted).font('Helvetica-Bold').fontSize(8).text(formatDateTime(note.updated_at), page.margin, y);
y = fitText(doc, note.body, page.margin, y + 12, contentWidth, { height: 44, ellipsis: true });
y += 8;
doc.moveTo(page.margin, y).lineTo(page.width - page.margin, y).strokeColor(colors.border).stroke();
y += 8;
});
}
doc.end();
return output;
};
+99
View File
@@ -0,0 +1,99 @@
import path from 'path';
import sharp from 'sharp';
import { listFlockNotes } from '../repositories/auditRepository.js';
import { getBirdById, listVetVisitsForBird, listWeightsForBird } from '../repositories/birdRepository.js';
import { getS3ImageStorageConfig } from '../storage/imageStorageConfig.js';
import { getSignedS3ObjectUrl } from '../storage/s3Client.js';
import type { BirdRow } from '../types.js';
import { renderAdoptionReportPdf } from './adoptionReport.js';
const adoptionReportWeightHistoryDays = 14;
const parseDataImage = (value: string | null) => {
if (!value) {
return null;
}
const match = value.match(/^data:image\/(?:png|jpeg|jpg|webp|gif);base64,(.+)$/);
return match ? Buffer.from(match[1], 'base64') : null;
};
const normalizeReportPhotoBuffer = async (imageBuffer: Buffer | null) => {
if (!imageBuffer) {
return null;
}
try {
return await sharp(imageBuffer).rotate().png().toBuffer();
} catch (error) {
console.warn('Unable to normalize bird photo for adoption report:', error);
return null;
}
};
const loadBirdReportPhotoBuffer = async (bird: BirdRow) => {
if (!bird.photo_object_key) {
return normalizeReportPhotoBuffer(parseDataImage(bird.photo_data_url));
}
const s3Config = getS3ImageStorageConfig();
if (!s3Config) {
return null;
}
const signedUrl = getSignedS3ObjectUrl({
config: s3Config,
objectKey: bird.photo_object_key,
expiresInSeconds: 5 * 60,
});
const imageResponse = await fetch(signedUrl);
if (!imageResponse.ok) {
return null;
}
return normalizeReportPhotoBuffer(Buffer.from(await imageResponse.arrayBuffer()));
};
export const renderAdoptionReportForBird = async ({
birdId,
workspaceId,
transferCode,
printFriendly,
}: {
birdId: string;
workspaceId: number;
transferCode: string;
printFriendly: boolean;
}) => {
const bird = await getBirdById(birdId, workspaceId);
if (!bird) {
throw new Error('Bird not found.');
}
const [weights, vetVisits, notes, birdPhotoBuffer] = await Promise.all([
listWeightsForBird(bird.id, workspaceId, adoptionReportWeightHistoryDays),
listVetVisitsForBird(bird.id, workspaceId),
listFlockNotes(workspaceId),
loadBirdReportPhotoBuffer(bird),
]);
const birdNotes = notes.filter((note) => note.bird_id === bird.id);
return renderAdoptionReportPdf({
bird,
weights,
vetVisits,
notes: birdNotes,
transferCode,
birdPhotoBuffer,
printFriendly,
assets: {
logoPath: path.join(process.cwd(), 'assets', 'flockpal-logo.png'),
wordmarkPath: path.join(process.cwd(), 'assets', 'flockpal-text.png'),
defaultBirdPhotoPath: path.join(process.cwd(), 'assets', 'yoda-default.png'),
},
});
};
+133
View File
@@ -0,0 +1,133 @@
import { db } from '../db/client.js';
import type { AuditLogEntryRow, AuthContext, FlockNoteRow } from '../types.js';
type AuditLogInput = {
workspaceId: number;
auth?: AuthContext;
action: string;
entityType: string;
entityId?: string | null;
entityName?: string | null;
details?: Record<string, unknown>;
};
export const createAuditLogEntry = async ({
workspaceId,
auth,
action,
entityType,
entityId = null,
entityName = null,
details = {},
}: AuditLogInput) => {
const result = await db.query<AuditLogEntryRow>(
`INSERT INTO audit_log_entries (workspace_id, user_id, actor_name, actor_email, action, entity_type, entity_id, entity_name, details)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, workspace_id, user_id, actor_name, actor_email, action, entity_type, entity_id, entity_name, details, created_at`,
[
workspaceId,
auth?.user.id ?? null,
auth?.user.name ?? null,
auth?.user.email ?? null,
action,
entityType,
entityId,
entityName,
JSON.stringify(details),
],
);
return result.rows[0] ?? null;
};
export const listAuditLogEntries = async (workspaceId: number, limit = 100) => {
const result = await db.query<AuditLogEntryRow>(
`SELECT id, workspace_id, user_id, actor_name, actor_email, action, entity_type, entity_id, entity_name, details, created_at
FROM audit_log_entries
WHERE workspace_id = $1
ORDER BY created_at DESC
LIMIT $2`,
[workspaceId, limit],
);
return result.rows;
};
export const listFlockNotes = async (workspaceId: number) => {
const result = await db.query<FlockNoteRow>(
`SELECT flock_notes.id,
flock_notes.workspace_id,
flock_notes.bird_id,
birds.name AS bird_name,
flock_notes.title,
flock_notes.body,
flock_notes.created_by_user_id,
users.name AS created_by_name,
flock_notes.created_at,
flock_notes.updated_at
FROM flock_notes
LEFT JOIN birds ON birds.id = flock_notes.bird_id
LEFT JOIN users ON users.id = flock_notes.created_by_user_id
WHERE flock_notes.workspace_id = $1
ORDER BY flock_notes.updated_at DESC`,
[workspaceId],
);
return result.rows;
};
export const createFlockNote = async ({
workspaceId,
birdId,
body,
createdByUserId,
}: {
workspaceId: number;
birdId: string | null;
body: string;
createdByUserId: string | null;
}) => {
const title = body.split(/\s+/).join(' ').slice(0, 160) || 'Note';
const result = await db.query<FlockNoteRow>(
`WITH inserted_note AS (
INSERT INTO flock_notes (workspace_id, bird_id, title, body, created_by_user_id)
SELECT $1, $2, $3, $4, $5
WHERE $2::uuid IS NULL
OR EXISTS (
SELECT 1
FROM birds
WHERE birds.id = $2
AND birds.workspace_id = $1
)
RETURNING id, workspace_id, bird_id, title, body, created_by_user_id, created_at, updated_at
)
SELECT inserted_note.id,
inserted_note.workspace_id,
inserted_note.bird_id,
birds.name AS bird_name,
inserted_note.title,
inserted_note.body,
inserted_note.created_by_user_id,
users.name AS created_by_name,
inserted_note.created_at,
inserted_note.updated_at
FROM inserted_note
LEFT JOIN birds ON birds.id = inserted_note.bird_id
LEFT JOIN users ON users.id = inserted_note.created_by_user_id`,
[workspaceId, birdId, title, body, createdByUserId],
);
return result.rows[0] ?? null;
};
export const deleteFlockNote = async (noteId: string, workspaceId: number) => {
const result = await db.query<{ id: string; title: string }>(
`DELETE FROM flock_notes
WHERE id = $1
AND workspace_id = $2
RETURNING id, title`,
[noteId, workspaceId],
);
return result.rows[0] ?? null;
};
@@ -31,6 +31,9 @@ test('createBird returns the inserted bird row', async () => {
name: 'Kiwi', name: 'Kiwi',
tag_id: 'A-1', tag_id: 'A-1',
species: 'Cockatiel', species: 'Cockatiel',
motivators: 'Step-up practice',
demotivators: 'Vacuum noise',
favorite_snack: 'Millet',
gender: 'female', gender: 'female',
date_of_birth: null, date_of_birth: null,
gotcha_day: null, gotcha_day: null,
@@ -50,6 +53,9 @@ test('createBird returns the inserted bird row', async () => {
name: 'Kiwi', name: 'Kiwi',
tagId: 'A-1', tagId: 'A-1',
species: 'Cockatiel', species: 'Cockatiel',
motivators: 'Step-up practice',
demotivators: 'Vacuum noise',
favoriteSnack: 'Millet',
gender: 'female', gender: 'female',
dateOfBirth: null, dateOfBirth: null,
gotchaDay: null, gotchaDay: null,
@@ -62,6 +68,7 @@ test('createBird returns the inserted bird row', async () => {
assert.equal(bird?.name, 'Kiwi'); assert.equal(bird?.name, 'Kiwi');
assert.equal(bird?.workspace_id, 10); assert.equal(bird?.workspace_id, 10);
assert.equal(bird?.gender, 'female'); assert.equal(bird?.gender, 'female');
assert.equal(bird?.favorite_snack, 'Millet');
}); });
test('listWeightsForBird scopes by bird, workspace, and day window', async () => { test('listWeightsForBird scopes by bird, workspace, and day window', async () => {
@@ -181,6 +188,46 @@ test('completePendingBirdTransfersForOwner moves pending birds and marks complet
], ],
}, },
{ rowCount: 1, rows: [] }, { rowCount: 1, rows: [] },
{
rowCount: 1,
rows: [
{
workspace_id: 10,
workspace_name: 'Original Flock',
owner_email: 'sender@example.com',
},
],
},
{
rowCount: 1,
rows: [
{
workspace_id: 22,
workspace_name: 'Receiving Flock',
owner_email: 'receiver@example.com',
},
],
},
{
rowCount: 1,
rows: [
{
id: 'timeline-1',
bird_id: 'bird-1',
event_type: 'transferred',
from_workspace_id: 10,
to_workspace_id: 22,
from_workspace_name: 'Original Flock',
to_workspace_name: 'Receiving Flock',
from_owner_email: 'sender@example.com',
to_owner_email: 'receiver@example.com',
location_label: 'Receiving Flock',
location_details: null,
created_by_user_id: 'user-1',
created_at: '2026-04-15T00:00:00.000Z',
},
],
},
); );
const result = await completePendingBirdTransfersForOwner('receiver@example.com', 22); const result = await completePendingBirdTransfersForOwner('receiver@example.com', 22);
@@ -190,4 +237,19 @@ test('completePendingBirdTransfersForOwner moves pending birds and marks complet
assert.deepEqual(calls[1].params, ['bird-1', 10, 22]); assert.deepEqual(calls[1].params, ['bird-1', 10, 22]);
assert.deepEqual(calls[2].params, ['transfer-1', 22]); assert.deepEqual(calls[2].params, ['transfer-1', 22]);
assert.match(calls[2].text, /completed_at = CURRENT_TIMESTAMP/); assert.match(calls[2].text, /completed_at = CURRENT_TIMESTAMP/);
assert.deepEqual(calls[5].params, [
'bird-1',
'transferred',
10,
22,
'Original Flock',
'Receiving Flock',
'sender@example.com',
'receiver@example.com',
null,
null,
null,
'user-1',
null,
]);
}); });
+518 -26
View File
@@ -5,9 +5,14 @@ import type {
BirdMilestoneReminderDeliveryRow, BirdMilestoneReminderDeliveryRow,
BirdMilestoneReminderType, BirdMilestoneReminderType,
BirdRow, BirdRow,
BirdTimelineEventRow,
BirdTimelineEventType,
BirdTransferCodeRow,
LostBirdMatchRow, LostBirdMatchRow,
MedicationAdministrationRow, MedicationAdministrationRow,
MedicationDoseScheduleItem, MedicationDoseScheduleItem,
MedicationReminderCandidateRow,
MedicationReminderDeliveryRow,
MedicationRow, MedicationRow,
PendingBirdTransferRow, PendingBirdTransferRow,
VetVisitRow, VetVisitRow,
@@ -20,13 +25,27 @@ const birdSelectFields = `
birds.name, birds.name,
birds.tag_id, birds.tag_id,
birds.species, birds.species,
birds.motivators,
birds.demotivators,
birds.favorite_snack,
birds.location_label,
birds.location_details,
birds.vet_clinic_name,
birds.vet_clinic_address,
birds.vet_account_number,
birds.vet_doctor_name,
birds.gender, birds.gender,
birds.date_of_birth::text, birds.date_of_birth::text,
birds.gotcha_day::text, birds.gotcha_day::text,
birds.chart_color, birds.chart_color,
birds.photo_data_url, birds.photo_data_url,
birds.photo_object_key,
birds.photo_content_type,
birds.photo_updated_at,
birds.notify_on_dob, birds.notify_on_dob,
birds.notify_on_gotcha_day, birds.notify_on_gotcha_day,
birds.public_profile_code,
birds.public_profile_enabled,
birds.memorialized_at, birds.memorialized_at,
birds.memorialized_on::text, birds.memorialized_on::text,
birds.memorial_note, birds.memorial_note,
@@ -36,6 +55,34 @@ const birdSelectFields = `
latest.recorded_on::text AS latest_recorded_on latest.recorded_on::text AS latest_recorded_on
`; `;
type WorkspaceTimelineSnapshot = {
workspace_id: number;
workspace_name: string;
owner_email: string | null;
};
const getWorkspaceTimelineSnapshot = async (workspaceId: number) => {
const result = await db.query<WorkspaceTimelineSnapshot>(
`SELECT
workspaces.id AS workspace_id,
workspaces.name AS workspace_name,
COALESCE(workspaces.billing_email, owner_member.invite_email, owner_member.email) AS owner_email
FROM workspaces
LEFT JOIN LATERAL (
SELECT invite_email, email
FROM workspace_members
WHERE workspace_members.workspace_id = workspaces.id
AND workspace_members.role = 'owner'
ORDER BY accepted_at DESC NULLS LAST, created_at ASC
LIMIT 1
) owner_member ON TRUE
WHERE workspaces.id = $1`,
[workspaceId],
);
return result.rows[0] ?? null;
};
export const getBirdById = async (birdId: string, workspaceId: number) => { export const getBirdById = async (birdId: string, workspaceId: number) => {
const result = await db.query<BirdRow>( const result = await db.query<BirdRow>(
`SELECT `SELECT
@@ -56,6 +103,27 @@ export const getBirdById = async (birdId: string, workspaceId: number) => {
return result.rows[0] ?? null; return result.rows[0] ?? null;
}; };
export const getBirdByPublicProfileCode = async (publicProfileCode: string) => {
const result = await db.query<BirdRow>(
`SELECT
${birdSelectFields}
FROM birds
LEFT JOIN LATERAL (
SELECT weight_grams, recorded_on
FROM weight_records
WHERE weight_records.bird_id = birds.id
ORDER BY recorded_on DESC
LIMIT 1
) latest ON TRUE
WHERE birds.public_profile_code = $1
AND birds.public_profile_enabled = TRUE
AND birds.memorialized_at IS NULL`,
[publicProfileCode],
);
return result.rows[0] ?? null;
};
export const listBirds = async (workspaceId: number) => { export const listBirds = async (workspaceId: number) => {
const result = await db.query<BirdRow>( const result = await db.query<BirdRow>(
`SELECT `SELECT
@@ -98,6 +166,102 @@ export const listMemorializedBirds = async (workspaceId: number) => {
return result.rows; return result.rows;
}; };
export const createBirdTimelineEvent = async ({
birdId,
eventType,
fromWorkspaceId,
toWorkspaceId,
locationLabel,
locationDetails,
note,
eventDate,
createdByUserId,
}: {
birdId: string;
eventType: BirdTimelineEventType;
fromWorkspaceId?: number | null;
toWorkspaceId?: number | null;
locationLabel?: string | null;
locationDetails?: Record<string, unknown> | null;
note?: string | null;
eventDate?: string | null;
createdByUserId?: string | null;
}) => {
const [fromWorkspace, toWorkspace] = await Promise.all([
fromWorkspaceId ? getWorkspaceTimelineSnapshot(fromWorkspaceId) : Promise.resolve(null),
toWorkspaceId ? getWorkspaceTimelineSnapshot(toWorkspaceId) : Promise.resolve(null),
]);
const result = await db.query<BirdTimelineEventRow>(
`INSERT INTO bird_timeline_events (
bird_id,
event_type,
from_workspace_id,
to_workspace_id,
from_workspace_name,
to_workspace_name,
from_owner_email,
to_owner_email,
location_label,
note,
event_date,
created_by_user_id,
location_details
)
VALUES (
$1,
$2,
$3,
$4,
$5::varchar(160),
$6::varchar(160),
$7::varchar(320),
$8::varchar(320),
COALESCE($9::varchar(160), $6::varchar(160), $5::varchar(160)),
$10,
COALESCE($11::date, CURRENT_DATE),
$12,
$13
)
RETURNING id, bird_id, event_type, from_workspace_id, to_workspace_id, from_workspace_name, to_workspace_name, from_owner_email, to_owner_email, location_label, location_details, note, event_date::text, created_by_user_id, created_at`,
[
birdId,
eventType,
fromWorkspaceId ?? null,
toWorkspaceId ?? null,
fromWorkspace?.workspace_name ?? null,
toWorkspace?.workspace_name ?? null,
fromWorkspace?.owner_email ?? null,
toWorkspace?.owner_email ?? null,
locationLabel ?? null,
note ?? null,
eventDate ?? null,
createdByUserId ?? null,
locationDetails ?? null,
],
);
return result.rows[0] ?? null;
};
export const listBirdTimelineEvents = async (birdId: string, workspaceId: number) => {
const result = await db.query<BirdTimelineEventRow>(
`SELECT id, bird_id, event_type, from_workspace_id, to_workspace_id, from_workspace_name, to_workspace_name, from_owner_email, to_owner_email, location_label, location_details, note, event_date::text, created_by_user_id, created_at
FROM bird_timeline_events
WHERE bird_id = $1
AND EXISTS (
SELECT 1
FROM birds
WHERE birds.id = bird_timeline_events.bird_id
AND birds.workspace_id = $2
)
ORDER BY event_date DESC, created_at DESC`,
[birdId, workspaceId],
);
return result.rows;
};
export const findBirdsByBandId = async (tagId: string) => { export const findBirdsByBandId = async (tagId: string) => {
const result = await db.query<LostBirdMatchRow>( const result = await db.query<LostBirdMatchRow>(
`SELECT `SELECT
@@ -249,36 +413,166 @@ export const createBirdMilestoneReminderDelivery = async ({
return result.rows[0] ?? null; return result.rows[0] ?? null;
}; };
export const listDueMedicationReminders = async (runDate: string, currentTime: string) => {
const result = await db.query<MedicationReminderCandidateRow>(
`SELECT
${birdSelectFields},
workspaces.name AS workspace_name,
medications.id AS medication_id,
medications.name AS medication_name,
medications.dosage,
medications.frequency,
medications.dose_schedule,
medications.route,
medications.start_date::text AS medication_start_date,
medications.end_date::text AS medication_end_date,
medications.notes AS medication_notes,
$1::date::text AS scheduled_on,
dose.key AS administration_slot,
dose.label AS administration_label,
dose.time AS administration_time
FROM medications
INNER JOIN birds ON birds.id = medications.bird_id
INNER JOIN workspaces ON workspaces.id = birds.workspace_id
CROSS JOIN LATERAL jsonb_to_recordset(medications.dose_schedule) AS dose(key text, label text, time text)
LEFT JOIN LATERAL (
SELECT weight_grams, recorded_on
FROM weight_records
WHERE weight_records.bird_id = birds.id
ORDER BY recorded_on DESC
LIMIT 1
) latest ON TRUE
WHERE medications.reminders_enabled = TRUE
AND birds.memorialized_at IS NULL
AND medications.start_date <= $1::date
AND (medications.end_date IS NULL OR medications.end_date >= $1::date)
AND COALESCE(NULLIF(BTRIM(dose.time), ''), '') <> ''
AND dose.time <= $2
AND NOT EXISTS (
SELECT 1
FROM medication_reminder_deliveries deliveries
WHERE deliveries.medication_id = medications.id
AND deliveries.scheduled_on = $1::date
AND deliveries.administration_slot = dose.key
)
ORDER BY workspaces.name ASC, birds.name ASC, dose.time ASC, medications.name ASC`,
[runDate, currentTime],
);
return result.rows;
};
export const createMedicationReminderDelivery = async ({
medicationId,
birdId,
workspaceId,
scheduledOn,
administrationSlot,
}: {
medicationId: string;
birdId: string;
workspaceId: number;
scheduledOn: string;
administrationSlot: string;
}) => {
const result = await db.query<MedicationReminderDeliveryRow>(
`INSERT INTO medication_reminder_deliveries (medication_id, bird_id, workspace_id, scheduled_on, administration_slot)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (medication_id, scheduled_on, administration_slot) DO NOTHING
RETURNING id, medication_id, bird_id, workspace_id, scheduled_on::text, administration_slot, delivered_at`,
[medicationId, birdId, workspaceId, scheduledOn, administrationSlot],
);
return result.rows[0] ?? null;
};
export const createBird = async ({ export const createBird = async ({
birdId,
workspaceId, workspaceId,
name, name,
tagId, tagId,
species, species,
motivators,
demotivators,
favoriteSnack,
locationLabel = null,
locationDetails = null,
vetClinicName = null,
vetClinicAddress = null,
vetAccountNumber = null,
vetDoctorName = null,
gender, gender,
dateOfBirth, dateOfBirth,
gotchaDay, gotchaDay,
chartColor, chartColor,
photoDataUrl, photoDataUrl,
photoObjectKey = null,
photoContentType = null,
photoUpdatedAt = null,
notifyOnDob, notifyOnDob,
notifyOnGotchaDay, notifyOnGotchaDay,
publicProfileCode = null,
publicProfileEnabled = false,
}: { }: {
birdId?: string;
workspaceId: number; workspaceId: number;
name: string; name: string;
tagId: string | null; tagId: string | null;
species: string; species: string;
motivators: string | null;
demotivators: string | null;
favoriteSnack: string | null;
locationLabel?: string | null;
locationDetails?: Record<string, unknown> | null;
vetClinicName?: string | null;
vetClinicAddress?: string | null;
vetAccountNumber?: string | null;
vetDoctorName?: string | null;
gender: BirdGender; gender: BirdGender;
dateOfBirth: string | null; dateOfBirth: string | null;
gotchaDay: string | null; gotchaDay: string | null;
chartColor: string; chartColor: string;
photoDataUrl: string | null; photoDataUrl: string | null;
photoObjectKey?: string | null;
photoContentType?: string | null;
photoUpdatedAt?: string | null;
notifyOnDob: boolean; notifyOnDob: boolean;
notifyOnGotchaDay: boolean; notifyOnGotchaDay: boolean;
publicProfileCode?: string | null;
publicProfileEnabled?: boolean;
}) => { }) => {
const result = await db.query<BirdRow>( const result = await db.query<BirdRow>(
`INSERT INTO birds (workspace_id, name, tag_id, species, gender, date_of_birth, gotcha_day, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day) `INSERT INTO birds (id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, location_details, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth, gotcha_day, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) VALUES (COALESCE($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26)
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`, RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, location_details, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`,
[workspaceId, name, tagId, species, gender, dateOfBirth, gotchaDay, chartColor, photoDataUrl, notifyOnDob, notifyOnGotchaDay], [
birdId ?? null,
workspaceId,
name,
tagId,
species,
motivators,
demotivators,
favoriteSnack,
locationLabel,
locationDetails,
vetClinicName,
vetClinicAddress,
vetAccountNumber,
vetDoctorName,
gender,
dateOfBirth,
gotchaDay,
chartColor,
photoDataUrl,
photoObjectKey,
photoContentType,
photoUpdatedAt,
notifyOnDob,
notifyOnGotchaDay,
publicProfileCode,
publicProfileEnabled,
],
); );
return result.rows[0] ?? null; return result.rows[0] ?? null;
@@ -290,43 +584,85 @@ export const updateBird = async ({
name, name,
tagId, tagId,
species, species,
motivators,
demotivators,
favoriteSnack,
locationLabel,
locationDetails,
vetClinicName,
vetClinicAddress,
vetAccountNumber,
vetDoctorName,
gender, gender,
dateOfBirth, dateOfBirth,
gotchaDay, gotchaDay,
chartColor, chartColor,
photoDataUrl, photoDataUrl,
photoObjectKey = null,
photoContentType = null,
photoUpdatedAt = null,
notifyOnDob, notifyOnDob,
notifyOnGotchaDay, notifyOnGotchaDay,
publicProfileCode,
publicProfileEnabled,
}: { }: {
birdId: string; birdId: string;
workspaceId: number; workspaceId: number;
name: string; name: string;
tagId: string | null; tagId: string | null;
species: string; species: string;
motivators: string | null;
demotivators: string | null;
favoriteSnack: string | null;
locationLabel: string | null;
locationDetails?: Record<string, unknown> | null;
vetClinicName: string | null;
vetClinicAddress: string | null;
vetAccountNumber: string | null;
vetDoctorName: string | null;
gender: BirdGender; gender: BirdGender;
dateOfBirth: string | null; dateOfBirth: string | null;
gotchaDay: string | null; gotchaDay: string | null;
chartColor: string; chartColor: string;
photoDataUrl: string | null; photoDataUrl: string | null;
photoObjectKey?: string | null;
photoContentType?: string | null;
photoUpdatedAt?: string | null;
notifyOnDob: boolean; notifyOnDob: boolean;
notifyOnGotchaDay: boolean; notifyOnGotchaDay: boolean;
publicProfileCode: string | null;
publicProfileEnabled: boolean;
}) => { }) => {
const result = await db.query<BirdRow>( const result = await db.query<BirdRow>(
`UPDATE birds `UPDATE birds
SET name = $2, SET name = $2,
tag_id = $3, tag_id = $3,
species = $4, species = $4,
gender = $5, motivators = $5,
date_of_birth = $6, demotivators = $6,
gotcha_day = $7, favorite_snack = $7,
chart_color = $8, location_label = $8,
photo_data_url = $9, vet_clinic_name = $9,
notify_on_dob = $10, vet_clinic_address = $10,
notify_on_gotcha_day = $11 vet_account_number = $11,
vet_doctor_name = $12,
gender = $13,
date_of_birth = $14,
gotcha_day = $15,
chart_color = $16,
photo_data_url = $17,
photo_object_key = $18,
photo_content_type = $19,
photo_updated_at = $20,
notify_on_dob = $21,
notify_on_gotcha_day = $22,
public_profile_code = $23,
public_profile_enabled = $24,
location_details = $25
WHERE id = $1 WHERE id = $1
AND workspace_id = $12 AND workspace_id = $26
AND memorialized_at IS NULL AND memorialized_at IS NULL
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, location_details, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
( (
SELECT weight_grams::text SELECT weight_grams::text
FROM weight_records FROM weight_records
@@ -341,7 +677,34 @@ export const updateBird = async ({
ORDER BY recorded_on DESC ORDER BY recorded_on DESC
LIMIT 1 LIMIT 1
) AS latest_recorded_on`, ) AS latest_recorded_on`,
[birdId, name, tagId, species, gender, dateOfBirth, gotchaDay, chartColor, photoDataUrl, notifyOnDob, notifyOnGotchaDay, workspaceId], [
birdId,
name,
tagId,
species,
motivators,
demotivators,
favoriteSnack,
locationLabel,
vetClinicName,
vetClinicAddress,
vetAccountNumber,
vetDoctorName,
gender,
dateOfBirth,
gotchaDay,
chartColor,
photoDataUrl,
photoObjectKey,
photoContentType,
photoUpdatedAt,
notifyOnDob,
notifyOnGotchaDay,
publicProfileCode,
publicProfileEnabled,
locationDetails ?? null,
workspaceId,
],
); );
return result.rows[0] ?? null; return result.rows[0] ?? null;
@@ -369,7 +732,7 @@ export const memorializeBird = async ({
WHERE id = $1 WHERE id = $1
AND workspace_id = $2 AND workspace_id = $2
AND memorialized_at IS NULL AND memorialized_at IS NULL
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, location_details, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
( (
SELECT weight_grams::text SELECT weight_grams::text
FROM weight_records FROM weight_records
@@ -405,7 +768,7 @@ export const updateMemorialReminderPreference = async ({
WHERE id = $1 WHERE id = $1
AND workspace_id = $2 AND workspace_id = $2
AND memorialized_at IS NOT NULL AND memorialized_at IS NOT NULL
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, location_details, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
( (
SELECT weight_grams::text SELECT weight_grams::text
FROM weight_records FROM weight_records
@@ -445,7 +808,7 @@ export const transferBirdToWorkspace = async (birdId: string, sourceWorkspaceId:
WHERE id = $1 WHERE id = $1
AND workspace_id = $2 AND workspace_id = $2
AND memorialized_at IS NULL AND memorialized_at IS NULL
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, location_details, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
( (
SELECT weight_grams::text SELECT weight_grams::text
FROM weight_records FROM weight_records
@@ -541,12 +904,23 @@ export const completePendingBirdTransfersForOwner = async (ownerEmail: string, t
} }
await markPendingBirdTransferCompleted(transfer.id, targetWorkspaceId); await markPendingBirdTransferCompleted(transfer.id, targetWorkspaceId);
try {
await createBirdTimelineEvent({
birdId: bird.id,
eventType: 'transferred',
fromWorkspaceId: transfer.source_workspace_id,
toWorkspaceId: targetWorkspaceId,
createdByUserId: transfer.requested_by_user_id,
});
} catch (timelineError) {
console.error('Unable to write bird timeline event', timelineError);
}
completed += 1; completed += 1;
} catch (error) { } catch (error) {
failed += 1; failed += 1;
const message = const message =
typeof error === 'object' && error && 'code' in error && error.code === '23505' typeof error === 'object' && error && 'code' in error && error.code === '23505'
? 'The receiving flock already has a bird using the same band/tag ID.' ? 'That band/tag ID is already in use in FlockPal.'
: error instanceof Error : error instanceof Error
? error.message ? error.message
: 'Unable to complete pending bird transfer.'; : 'Unable to complete pending bird transfer.';
@@ -557,6 +931,93 @@ export const completePendingBirdTransfersForOwner = async (ownerEmail: string, t
return { completed, failed }; return { completed, failed };
}; };
export const createBirdTransferCode = async ({
code,
birdId,
sourceWorkspaceId,
requestedByUserId,
}: {
code: string;
birdId: string;
sourceWorkspaceId: number;
requestedByUserId: string;
}) => {
await db.query(
`UPDATE bird_transfer_codes
SET revoked_at = CURRENT_TIMESTAMP
WHERE bird_id = $1
AND source_workspace_id = $2
AND completed_at IS NULL
AND revoked_at IS NULL`,
[birdId, sourceWorkspaceId],
);
const result = await db.query<BirdTransferCodeRow>(
`INSERT INTO bird_transfer_codes (code, bird_id, source_workspace_id, requested_by_user_id)
VALUES ($1, $2, $3, $4)
RETURNING id, code, bird_id, source_workspace_id, requested_by_user_id, completed_at::text, completed_workspace_id, revoked_at::text, created_at`,
[code, birdId, sourceWorkspaceId, requestedByUserId],
);
return result.rows[0] ?? null;
};
export const getOpenBirdTransferCode = async (code: string) => {
const result = await db.query<
BirdRow & {
transfer_code_id: string;
code: string;
source_workspace_id: number;
requested_by_user_id: string;
completed_at: string | null;
completed_workspace_id: number | null;
revoked_at: string | null;
transfer_code_created_at: string;
workspace_name: string;
}
>(
`SELECT
bird_transfer_codes.id AS transfer_code_id,
bird_transfer_codes.code,
bird_transfer_codes.source_workspace_id,
bird_transfer_codes.requested_by_user_id,
bird_transfer_codes.completed_at::text,
bird_transfer_codes.completed_workspace_id,
bird_transfer_codes.revoked_at::text,
bird_transfer_codes.created_at AS transfer_code_created_at,
workspaces.name AS workspace_name,
${birdSelectFields}
FROM bird_transfer_codes
INNER JOIN birds ON birds.id = bird_transfer_codes.bird_id
INNER JOIN workspaces ON workspaces.id = bird_transfer_codes.source_workspace_id
LEFT JOIN LATERAL (
SELECT weight_grams, recorded_on
FROM weight_records
WHERE weight_records.bird_id = birds.id
ORDER BY recorded_on DESC
LIMIT 1
) latest ON TRUE
WHERE bird_transfer_codes.code = $1
AND bird_transfer_codes.completed_at IS NULL
AND bird_transfer_codes.revoked_at IS NULL
AND birds.workspace_id = bird_transfer_codes.source_workspace_id
AND birds.memorialized_at IS NULL`,
[code],
);
return result.rows[0] ?? null;
};
export const markBirdTransferCodeCompleted = async (codeId: string, completedWorkspaceId: number) => {
await db.query(
`UPDATE bird_transfer_codes
SET completed_at = CURRENT_TIMESTAMP,
completed_workspace_id = $2
WHERE id = $1`,
[codeId, completedWorkspaceId],
);
};
export const listWeightsForBird = async (birdId: string, workspaceId: number, days: number) => { export const listWeightsForBird = async (birdId: string, workspaceId: number, days: number) => {
const result = await db.query<WeightRow>( const result = await db.query<WeightRow>(
`SELECT id, bird_id, weight_grams, recorded_on::text, notes `SELECT id, bird_id, weight_grams, recorded_on::text, notes
@@ -587,6 +1048,34 @@ export const createWeightForBird = async (birdId: string, weightGrams: number, r
return result.rows[0] ?? null; return result.rows[0] ?? null;
}; };
export const updateWeightForBird = async (
weightId: string,
birdId: string,
weightGrams: number,
recordedOn: string,
notes: string | null,
) => {
const result = await db.query<WeightRow>(
`UPDATE weight_records
SET weight_grams = $3,
recorded_on = $4,
notes = $5
WHERE id = $1
AND bird_id = $2
AND id IN (
SELECT recent.id
FROM weight_records recent
WHERE recent.bird_id = $2
ORDER BY recent.recorded_on DESC, recent.created_at DESC
LIMIT 3
)
RETURNING id, bird_id, weight_grams, recorded_on::text, notes`,
[weightId, birdId, weightGrams, recordedOn, notes],
);
return result.rows[0] ?? null;
};
export const listVetVisitsForBird = async (birdId: string, workspaceId: number) => { export const listVetVisitsForBird = async (birdId: string, workspaceId: number) => {
const result = await db.query<VetVisitRow>( const result = await db.query<VetVisitRow>(
`SELECT id, bird_id, visited_on::text, clinic_name, reason, notes `SELECT id, bird_id, visited_on::text, clinic_name, reason, notes
@@ -653,7 +1142,7 @@ export const deleteVetVisitForBird = async (visitId: string, birdId: string) =>
export const listMedicationsForBird = async (birdId: string, workspaceId: number) => { export const listMedicationsForBird = async (birdId: string, workspaceId: number) => {
const result = await db.query<MedicationRow>( const result = await db.query<MedicationRow>(
`SELECT id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes `SELECT id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes, reminders_enabled
FROM medications FROM medications
WHERE bird_id = $1 WHERE bird_id = $1
AND EXISTS ( AND EXISTS (
@@ -679,12 +1168,13 @@ export const createMedicationForBird = async (
startDate: string, startDate: string,
endDate: string | null, endDate: string | null,
notes: string | null, notes: string | null,
remindersEnabled: boolean,
) => { ) => {
const result = await db.query<MedicationRow>( const result = await db.query<MedicationRow>(
`INSERT INTO medications (bird_id, name, dosage, frequency, dose_schedule, route, start_date, end_date, notes) `INSERT INTO medications (bird_id, name, dosage, frequency, dose_schedule, route, start_date, end_date, notes, reminders_enabled)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes`, RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes, reminders_enabled`,
[birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, notes], [birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, notes, remindersEnabled],
); );
return result.rows[0] ?? null; return result.rows[0] ?? null;
@@ -701,6 +1191,7 @@ export const updateMedicationForBird = async (
startDate: string, startDate: string,
endDate: string | null, endDate: string | null,
notes: string | null, notes: string | null,
remindersEnabled: boolean,
) => { ) => {
const result = await db.query<MedicationRow>( const result = await db.query<MedicationRow>(
`UPDATE medications `UPDATE medications
@@ -711,11 +1202,12 @@ export const updateMedicationForBird = async (
route = $7, route = $7,
start_date = $8, start_date = $8,
end_date = $9, end_date = $9,
notes = $10 notes = $10,
reminders_enabled = $11
WHERE id = $1 WHERE id = $1
AND bird_id = $2 AND bird_id = $2
RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes`, RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes, reminders_enabled`,
[medicationId, birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, notes], [medicationId, birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, notes, remindersEnabled],
); );
return result.rows[0] ?? null; return result.rows[0] ?? null;
@@ -0,0 +1,153 @@
import { db } from '../db/client.js';
import type { DailyEducationQuestion, DailyEducationRow, EducationQuestionRow } from '../types.js';
export const getEducationOptOut = async (userId: string) => {
const result = await db.query<{ education_opt_out: boolean }>(
`SELECT education_opt_out
FROM users
WHERE id = $1`,
[userId],
);
return result.rows[0]?.education_opt_out ?? false;
};
export const updateEducationOptOut = async (userId: string, educationOptOut: boolean) => {
const result = await db.query<{ education_opt_out: boolean }>(
`UPDATE users
SET education_opt_out = $2
WHERE id = $1
RETURNING education_opt_out`,
[userId, educationOptOut],
);
return result.rows[0]?.education_opt_out ?? educationOptOut;
};
export const getDailyEducationForDate = async (publishDate?: string) => {
const result = publishDate
? await db.query<DailyEducationRow>(
`SELECT id, publish_date::text, fact, quiz_questions, created_by_user_id, created_at, updated_at
FROM daily_education
WHERE publish_date = $1`,
[publishDate],
)
: await db.query<DailyEducationRow>(
`SELECT id, publish_date::text, fact, quiz_questions, created_by_user_id, created_at, updated_at
FROM daily_education
WHERE publish_date = CURRENT_DATE`,
);
return result.rows[0] ?? null;
};
export const listDailyEducationForAdmin = async () => {
const result = await db.query<DailyEducationRow>(
`SELECT id, publish_date::text, fact, quiz_questions, created_by_user_id, created_at, updated_at
FROM daily_education
ORDER BY publish_date DESC
LIMIT 120`,
);
return result.rows;
};
export const upsertDailyEducation = async ({
publishDate,
fact,
createdByUserId,
}: {
publishDate: string;
fact: string;
createdByUserId: string;
}) => {
const result = await db.query<DailyEducationRow>(
`INSERT INTO daily_education (publish_date, fact, created_by_user_id)
VALUES ($1, $2, $3)
ON CONFLICT (publish_date) DO UPDATE
SET fact = EXCLUDED.fact,
updated_at = CURRENT_TIMESTAMP
RETURNING id, publish_date::text, fact, quiz_questions, created_by_user_id, created_at, updated_at`,
[publishDate, fact, createdByUserId],
);
return result.rows[0];
};
export const listEducationQuestionsForAdmin = async () => {
const result = await db.query<EducationQuestionRow>(
`SELECT id, prompt, options, correct_answer_index, explanation, created_by_user_id, created_at, updated_at
FROM education_question_bank
ORDER BY updated_at DESC, created_at DESC
LIMIT 400`,
);
return result.rows;
};
export const listDailyEducationQuestions = async (seedDate?: string) => {
const result = await db.query<EducationQuestionRow>(
`SELECT id, prompt, options, correct_answer_index, explanation, created_by_user_id, created_at, updated_at
FROM education_question_bank
ORDER BY md5(COALESCE($1::text, CURRENT_DATE::text) || id::text)
LIMIT 4`,
[seedDate ?? null],
);
return result.rows;
};
export const createEducationQuestion = async ({
question,
createdByUserId,
}: {
question: DailyEducationQuestion;
createdByUserId: string;
}) => {
const result = await db.query<EducationQuestionRow>(
`INSERT INTO education_question_bank (prompt, options, correct_answer_index, explanation, created_by_user_id)
VALUES ($1, $2::jsonb, $3, $4, $5)
RETURNING id, prompt, options, correct_answer_index, explanation, created_by_user_id, created_at, updated_at`,
[question.prompt, JSON.stringify(question.options), question.correctAnswerIndex, question.explanation, createdByUserId],
);
return result.rows[0];
};
export const updateEducationQuestion = async (questionId: string, question: DailyEducationQuestion) => {
const result = await db.query<EducationQuestionRow>(
`UPDATE education_question_bank
SET prompt = $2,
options = $3::jsonb,
correct_answer_index = $4,
explanation = $5,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING id, prompt, options, correct_answer_index, explanation, created_by_user_id, created_at, updated_at`,
[questionId, question.prompt, JSON.stringify(question.options), question.correctAnswerIndex, question.explanation],
);
return result.rows[0] ?? null;
};
export const deleteEducationQuestion = async (questionId: string) => {
const result = await db.query<{ id: string }>(
`DELETE FROM education_question_bank
WHERE id = $1
RETURNING id`,
[questionId],
);
return Boolean(result.rowCount);
};
export const deleteDailyEducation = async (educationId: string) => {
const result = await db.query<{ id: string }>(
`DELETE FROM daily_education
WHERE id = $1
RETURNING id`,
[educationId],
);
return Boolean(result.rowCount);
};
@@ -3,12 +3,15 @@ import test from 'node:test';
import { import {
createWorkspace, createWorkspace,
deleteWorkspaceMember,
deleteWorkspaceIfEmpty, deleteWorkspaceIfEmpty,
ensureDefaultWorkspaceForUser,
ensurePersonalWorkspaceForUser, ensurePersonalWorkspaceForUser,
findAlternateWorkspaceForUser, findAlternateWorkspaceForUser,
getPlatformAdminSummary, getPlatformAdminSummary,
listOwnedWorkspacesByOwnerEmail, listOwnedWorkspacesByOwnerEmail,
updateWorkspace, updateWorkspace,
updateWorkspaceMemberRole,
} from './workspaceRepository.js'; } from './workspaceRepository.js';
import { mockDb } from '../test/mockDb.js'; import { mockDb } from '../test/mockDb.js';
import type { UserRow } from '../types.js'; import type { UserRow } from '../types.js';
@@ -34,6 +37,83 @@ test('ensurePersonalWorkspaceForUser returns an existing workspace without creat
assert.match(calls[0].text, /FROM workspace_members/); assert.match(calls[0].text, /FROM workspace_members/);
}); });
test('ensurePersonalWorkspaceForUser creates a fresh workspace instead of claiming the legacy seed flock', async () => {
const { calls } = mockDb(
{
rowCount: 0,
rows: [],
},
{
rowCount: 1,
rows: [{ next_id: 43 }],
},
{
rowCount: 1,
rows: [],
},
{
rowCount: 1,
rows: [],
},
);
const workspaceId = await ensurePersonalWorkspaceForUser(user);
assert.equal(workspaceId, 43);
assert.equal(calls.length, 4);
assert.match(calls[1].text, /SELECT COALESCE\(MAX\(id\), 0\) \+ 1 AS next_id FROM workspaces/);
assert.match(calls[2].text, /INSERT INTO workspaces/);
assert.match(calls[3].text, /INSERT INTO workspace_members/);
assert.deepEqual(calls[2].params, [43, "Owner's Flock", 'owner@example.com']);
});
test('ensureDefaultWorkspaceForUser reuses an existing rescue workspace without creating a household flock', async () => {
const { calls } = mockDb({
rowCount: 1,
rows: [{ workspace_id: 84 }],
});
const workspaceId = await ensureDefaultWorkspaceForUser(user);
assert.equal(workspaceId, 84);
assert.equal(calls.length, 1);
assert.match(calls[0].text, /FROM workspace_members/);
assert.doesNotMatch(calls[0].text, /workspaces\.workspace_type = 'standard'/);
});
test('ensureDefaultWorkspaceForUser creates a household flock when the user has no workspace', async () => {
const { calls } = mockDb(
{
rowCount: 0,
rows: [],
},
{
rowCount: 0,
rows: [],
},
{
rowCount: 1,
rows: [{ next_id: 43 }],
},
{
rowCount: 1,
rows: [],
},
{
rowCount: 1,
rows: [],
},
);
const workspaceId = await ensureDefaultWorkspaceForUser(user);
assert.equal(workspaceId, 43);
assert.equal(calls.length, 5);
assert.match(calls[0].text, /FROM workspace_members/);
assert.match(calls[1].text, /workspaces\.workspace_type = 'standard'/);
assert.match(calls[3].text, /INSERT INTO workspaces/);
});
test('createWorkspace inserts owner membership and returns the created workspace', async () => { test('createWorkspace inserts owner membership and returns the created workspace', async () => {
const { calls } = mockDb( const { calls } = mockDb(
{ rowCount: 1, rows: [] }, { rowCount: 1, rows: [] },
@@ -181,6 +261,263 @@ test('listOwnedWorkspacesByOwnerEmail resolves accepted owner flocks by email',
assert.match(calls[0].text, /workspaces\.id <> \$2/); assert.match(calls[0].text, /workspaces\.id <> \$2/);
}); });
test('updateWorkspaceMemberRole changes a non-owner member role', async () => {
const { calls } = mockDb({
rowCount: 1,
rows: [
{
id: 'member-1',
workspace_id: 42,
user_id: 'user-2',
invite_email: 'helper@example.com',
name: 'Helper',
role: 'viewer',
accepted_at: '2026-04-14T00:00:00.000Z',
created_at: '2026-04-14T00:00:00.000Z',
},
],
});
const member = await updateWorkspaceMemberRole({
memberId: 'member-1',
workspaceId: 42,
role: 'viewer',
requesterMemberId: 'owner-member',
requesterIsBillingOwner: false,
requesterRole: 'owner',
billingEmail: 'billing@example.com',
});
assert.equal(member?.role, 'viewer');
assert.deepEqual(calls[0].params, ['member-1', 42, 'viewer', false, 'owner-member', 'billing@example.com', 'owner']);
assert.match(calls[0].text, /UPDATE workspace_members/);
assert.match(calls[0].text, /role <> 'owner'/);
});
test('updateWorkspaceMemberRole returns null when no non-owner member matches', async () => {
mockDb({
rowCount: 0,
rows: [],
});
const member = await updateWorkspaceMemberRole({
memberId: 'owner-member',
workspaceId: 42,
role: 'viewer',
requesterMemberId: 'owner-member',
requesterIsBillingOwner: false,
requesterRole: 'owner',
billingEmail: 'billing@example.com',
});
assert.equal(member, null);
});
test('updateWorkspaceMemberRole lets the billing owner change another owner role', async () => {
const { calls } = mockDb({
rowCount: 1,
rows: [
{
id: 'other-owner',
workspace_id: 42,
user_id: 'user-2',
invite_email: 'other@example.com',
name: 'Other Owner',
role: 'assistant',
accepted_at: '2026-04-14T00:00:00.000Z',
created_at: '2026-04-14T00:00:00.000Z',
},
],
});
const member = await updateWorkspaceMemberRole({
memberId: 'other-owner',
workspaceId: 42,
role: 'assistant',
requesterMemberId: 'billing-owner',
requesterIsBillingOwner: true,
requesterRole: 'owner',
billingEmail: 'billing@example.com',
});
assert.equal(member?.role, 'assistant');
assert.deepEqual(calls[0].params, ['other-owner', 42, 'assistant', true, 'billing-owner', 'billing@example.com', 'owner']);
assert.match(calls[0].text, /id <> \$5/);
});
test('updateWorkspaceMemberRole does not let the billing owner change their own owner role', async () => {
mockDb({
rowCount: 0,
rows: [],
});
const member = await updateWorkspaceMemberRole({
memberId: 'billing-owner',
workspaceId: 42,
role: 'assistant',
requesterMemberId: 'billing-owner',
requesterIsBillingOwner: true,
requesterRole: 'owner',
billingEmail: 'billing@example.com',
});
assert.equal(member, null);
});
test('updateWorkspaceMemberRole lets a non-billing owner change another non-billing owner role', async () => {
const { calls } = mockDb({
rowCount: 1,
rows: [
{
id: 'other-owner',
workspace_id: 42,
user_id: 'user-2',
invite_email: 'other@example.com',
name: 'Other Owner',
role: 'assistant',
accepted_at: '2026-04-14T00:00:00.000Z',
created_at: '2026-04-14T00:00:00.000Z',
},
],
});
const member = await updateWorkspaceMemberRole({
memberId: 'other-owner',
workspaceId: 42,
role: 'assistant',
requesterMemberId: 'non-billing-owner',
requesterIsBillingOwner: false,
requesterRole: 'owner',
billingEmail: 'billing@example.com',
});
assert.equal(member?.role, 'assistant');
assert.deepEqual(calls[0].params, ['other-owner', 42, 'assistant', false, 'non-billing-owner', 'billing@example.com', 'owner']);
assert.match(calls[0].text, /LOWER\(BTRIM\(COALESCE\(invite_email, email\)\)\) <> LOWER\(BTRIM\(\$6\)\)/);
});
test('updateWorkspaceMemberRole does not let a non-billing owner change the billing owner role', async () => {
mockDb({
rowCount: 0,
rows: [],
});
const member = await updateWorkspaceMemberRole({
memberId: 'billing-owner',
workspaceId: 42,
role: 'assistant',
requesterMemberId: 'non-billing-owner',
requesterIsBillingOwner: false,
requesterRole: 'owner',
billingEmail: 'billing@example.com',
});
assert.equal(member, null);
});
test('updateWorkspaceMemberRole lets the billing owner promote a non-owner to owner', async () => {
const { calls } = mockDb({
rowCount: 1,
rows: [
{
id: 'member-1',
workspace_id: 42,
user_id: 'user-2',
invite_email: 'helper@example.com',
name: 'Helper',
role: 'owner',
accepted_at: '2026-04-14T00:00:00.000Z',
created_at: '2026-04-14T00:00:00.000Z',
},
],
});
const member = await updateWorkspaceMemberRole({
memberId: 'member-1',
workspaceId: 42,
role: 'owner',
requesterMemberId: 'billing-owner',
requesterIsBillingOwner: true,
requesterRole: 'owner',
billingEmail: 'billing@example.com',
});
assert.equal(member?.role, 'owner');
assert.deepEqual(calls[0].params, ['member-1', 42, 'owner', true, 'billing-owner', 'billing@example.com', 'owner']);
assert.match(calls[0].text, /\$3 <> 'owner'/);
});
test('updateWorkspaceMemberRole does not let a non-billing owner promote a member to owner', async () => {
mockDb({
rowCount: 0,
rows: [],
});
const member = await updateWorkspaceMemberRole({
memberId: 'member-1',
workspaceId: 42,
role: 'owner',
requesterMemberId: 'non-billing-owner',
requesterIsBillingOwner: false,
requesterRole: 'owner',
billingEmail: 'billing@example.com',
});
assert.equal(member, null);
});
test('deleteWorkspaceMember removes non-owner members without billing owner access', async () => {
const { calls } = mockDb({
rowCount: 1,
rows: [{ id: 'member-1' }],
});
const deleted = await deleteWorkspaceMember({
memberId: 'member-1',
workspaceId: 42,
requesterMemberId: 'owner-member',
requesterIsBillingOwner: false,
});
assert.equal(deleted, true);
assert.deepEqual(calls[0].params, ['member-1', 42, false, 'owner-member']);
assert.match(calls[0].text, /role <> 'owner'/);
});
test('deleteWorkspaceMember lets the billing owner remove another owner', async () => {
const { calls } = mockDb({
rowCount: 1,
rows: [{ id: 'other-owner' }],
});
const deleted = await deleteWorkspaceMember({
memberId: 'other-owner',
workspaceId: 42,
requesterMemberId: 'billing-owner',
requesterIsBillingOwner: true,
});
assert.equal(deleted, true);
assert.deepEqual(calls[0].params, ['other-owner', 42, true, 'billing-owner']);
assert.match(calls[0].text, /id <> \$4/);
});
test('deleteWorkspaceMember does not let the billing owner remove their own owner membership', async () => {
mockDb({
rowCount: 0,
rows: [],
});
const deleted = await deleteWorkspaceMember({
memberId: 'billing-owner',
workspaceId: 42,
requesterMemberId: 'billing-owner',
requesterIsBillingOwner: true,
});
assert.equal(deleted, false);
});
test('getPlatformAdminSummary counts memorialized birds separately', async () => { test('getPlatformAdminSummary counts memorialized birds separately', async () => {
const { calls } = mockDb({ const { calls } = mockDb({
rowCount: 1, rowCount: 1,
+85 -36
View File
@@ -91,39 +91,13 @@ export const ensurePersonalWorkspaceForUser = async (user: UserRow) => {
return Number(existing.rows[0].workspace_id); return Number(existing.rows[0].workspace_id);
} }
const unclaimed = await db.query<{ workspace_id: number }>( const workspaceId = await getNextWorkspaceId();
`SELECT workspaces.id AS workspace_id
FROM workspaces
LEFT JOIN workspace_members ON workspace_members.workspace_id = workspaces.id
WHERE workspaces.id = 1
GROUP BY workspaces.id
HAVING COUNT(workspace_members.id) = 0
LIMIT 1`,
);
const workspaceId = unclaimed.rowCount ? Number(unclaimed.rows[0].workspace_id) : await getNextWorkspaceId();
if (!unclaimed.rowCount) {
await db.query( await db.query(
`INSERT INTO workspaces (id, name, workspace_type, billing_plan, billing_interval, billing_email, subscription_status, rescue_verification_status) `INSERT INTO workspaces (id, name, workspace_type, billing_plan, billing_interval, billing_email, subscription_status, rescue_verification_status)
VALUES ($1, $2, 'standard', 'household_basic', 'monthly', $3, 'none', 'not_required')`, VALUES ($1, $2, 'standard', 'household_basic', 'monthly', $3, 'none', 'not_required')`,
[workspaceId, `${user.name}'s Flock`, user.email], [workspaceId, `${user.name}'s Flock`, user.email],
); );
} else {
await db.query(
`UPDATE workspaces
SET name = $2,
workspace_type = 'standard',
billing_plan = 'household_basic',
billing_interval = 'monthly',
billing_email = $3,
subscription_status = 'none',
rescue_verification_status = 'not_required',
updated_at = CURRENT_TIMESTAMP
WHERE id = $1`,
[workspaceId, `${user.name}'s Flock`, user.email],
);
}
await db.query( await db.query(
`INSERT INTO workspace_members (workspace_id, user_id, email, invite_email, name, role, accepted_at) `INSERT INTO workspace_members (workspace_id, user_id, email, invite_email, name, role, accepted_at)
@@ -140,6 +114,24 @@ export const ensurePersonalWorkspaceForUser = async (user: UserRow) => {
return workspaceId; return workspaceId;
}; };
export const ensureDefaultWorkspaceForUser = async (user: UserRow) => {
const existing = await db.query<{ workspace_id: number }>(
`SELECT workspace_id
FROM workspace_members
INNER JOIN workspaces ON workspaces.id = workspace_members.workspace_id
WHERE workspace_members.user_id = $1
ORDER BY workspaces.created_at ASC
LIMIT 1`,
[user.id],
);
if (existing.rowCount) {
return Number(existing.rows[0].workspace_id);
}
return ensurePersonalWorkspaceForUser(user);
};
export const claimWorkspaceInvites = async (user: UserRow) => { export const claimWorkspaceInvites = async (user: UserRow) => {
await db.query( await db.query(
`UPDATE workspace_members `UPDATE workspace_members
@@ -372,23 +364,84 @@ export const upsertWorkspaceMember = async ({
return result.rows[0] ?? null; return result.rows[0] ?? null;
}; };
export const deleteWorkspaceMember = async (memberId: string, workspaceId: number) => { export const deleteWorkspaceMember = async ({
memberId,
workspaceId,
requesterMemberId,
requesterIsBillingOwner,
}: {
memberId: string;
workspaceId: number;
requesterMemberId: string;
requesterIsBillingOwner: boolean;
}) => {
const result = await db.query<{ id: string }>( const result = await db.query<{ id: string }>(
`DELETE FROM workspace_members `DELETE FROM workspace_members
WHERE id = $1 WHERE id = $1
AND workspace_id = $2 AND workspace_id = $2
AND role <> 'owner' AND (
role <> 'owner'
OR (
$3 = TRUE
AND id <> $4
)
)
RETURNING id`, RETURNING id`,
[memberId, workspaceId], [memberId, workspaceId, requesterIsBillingOwner, requesterMemberId],
); );
return Boolean(result.rowCount); return Boolean(result.rowCount);
}; };
export const updateWorkspaceMemberRole = async ({
memberId,
workspaceId,
role,
requesterMemberId,
requesterIsBillingOwner,
requesterRole,
billingEmail,
}: {
memberId: string;
workspaceId: number;
role: WorkspaceMemberRow['role'];
requesterMemberId: string;
requesterIsBillingOwner: boolean;
requesterRole: WorkspaceMemberRow['role'];
billingEmail: string;
}) => {
const result = await db.query<WorkspaceMemberRow>(
`UPDATE workspace_members
SET role = $3
WHERE id = $1
AND workspace_id = $2
AND (
$3 <> 'owner'
OR $4 = TRUE
)
AND (
role <> 'owner'
OR (
id <> $5
AND (
$4 = TRUE
OR (
$7 = 'owner'
AND LOWER(BTRIM(COALESCE(invite_email, email))) <> LOWER(BTRIM($6))
)
)
)
)
RETURNING id, workspace_id, user_id, COALESCE(invite_email, email) AS invite_email, name, role, accepted_at::text, created_at`,
[memberId, workspaceId, role, requesterIsBillingOwner, requesterMemberId, billingEmail, requesterRole],
);
return result.rows[0] ?? null;
};
export const listRescueWorkspacesForAdmin = async () => { export const listRescueWorkspacesForAdmin = async () => {
const result = await db.query< const result = await db.query<
WorkspaceRow & { WorkspaceRow & {
owner_email: string | null;
bird_count: number; bird_count: number;
member_count: number; member_count: number;
} }
@@ -406,17 +459,13 @@ export const listRescueWorkspacesForAdmin = async () => {
workspaces.rescue_verification_status, workspaces.rescue_verification_status,
workspaces.created_at, workspaces.created_at,
workspaces.updated_at, workspaces.updated_at,
owner.invite_email AS owner_email,
COUNT(DISTINCT birds.id)::int AS bird_count, COUNT(DISTINCT birds.id)::int AS bird_count,
COUNT(DISTINCT workspace_members.id)::int AS member_count COUNT(DISTINCT workspace_members.id)::int AS member_count
FROM workspaces FROM workspaces
LEFT JOIN workspace_members owner
ON owner.workspace_id = workspaces.id
AND owner.role = 'owner'
LEFT JOIN birds ON birds.workspace_id = workspaces.id LEFT JOIN birds ON birds.workspace_id = workspaces.id
LEFT JOIN workspace_members ON workspace_members.workspace_id = workspaces.id LEFT JOIN workspace_members ON workspace_members.workspace_id = workspaces.id
WHERE workspaces.workspace_type = 'rescue' WHERE workspaces.workspace_type = 'rescue'
GROUP BY workspaces.id, owner.invite_email GROUP BY workspaces.id
ORDER BY ORDER BY
CASE workspaces.rescue_verification_status CASE workspaces.rescue_verification_status
WHEN 'pending' THEN 0 WHEN 'pending' THEN 0
@@ -0,0 +1,134 @@
import { db } from '../db/client.js';
import { ensureSchema } from '../db/schema.js';
import { buildBirdPhotoObjectKey, getImageExtensionFromContentType, getS3ImageStorageConfig } from '../storage/imageStorageConfig.js';
import { deleteS3Object, putS3Object } from '../storage/s3Client.js';
type BirdPhotoMigrationRow = {
id: string;
workspace_id: number;
name: string;
photo_data_url: string;
};
const parseDataImage = (dataUrl: string) => {
const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/.exec(dataUrl);
if (!match) {
return null;
}
return {
contentType: match[1],
content: Buffer.from(match[2], 'base64'),
};
};
const getArgValue = (name: string) => {
const prefix = `${name}=`;
const match = process.argv.find((arg) => arg.startsWith(prefix));
return match ? match.slice(prefix.length) : null;
};
const dryRun = !process.argv.includes('--apply');
const keepDataUrl = process.argv.includes('--keep-data-url');
const limitArg = getArgValue('--limit');
const limit = limitArg ? Number(limitArg) : null;
if (limit !== null && (!Number.isInteger(limit) || limit <= 0)) {
console.error('Invalid --limit value. Use a positive integer.');
process.exit(2);
}
const run = async () => {
await ensureSchema();
const s3Config = getS3ImageStorageConfig();
if (!s3Config) {
throw new Error('S3 image storage is not fully configured. Set IMAGE_STORAGE_PROVIDER=s3 and the S3_* environment variables.');
}
const result = await db.query<BirdPhotoMigrationRow>(
`SELECT id, workspace_id, name, photo_data_url
FROM birds
WHERE photo_object_key IS NULL
AND photo_data_url LIKE 'data:image/%'
ORDER BY created_at ASC
${limit ? 'LIMIT $1' : ''}`,
limit ? [limit] : undefined,
);
if (dryRun) {
console.log(`Dry run: ${result.rows.length} bird photo(s) would be migrated to bucket ${s3Config.bucket}.`);
console.log('Run with --apply to upload objects and update rows.');
return;
}
let migrated = 0;
let skipped = 0;
let failed = 0;
for (const bird of result.rows) {
const parsedImage = parseDataImage(bird.photo_data_url);
if (!parsedImage) {
skipped += 1;
console.warn(`Skipping bird ${bird.id} (${bird.name}): invalid data URL.`);
continue;
}
const objectKey = buildBirdPhotoObjectKey({
workspaceId: bird.workspace_id,
birdId: bird.id,
extension: getImageExtensionFromContentType(parsedImage.contentType),
});
try {
await putS3Object({
config: s3Config,
objectKey,
content: parsedImage.content,
contentType: parsedImage.contentType,
});
const updateResult = await db.query(
`UPDATE birds
SET photo_object_key = $2,
photo_content_type = $3,
photo_updated_at = CURRENT_TIMESTAMP,
photo_data_url = CASE WHEN $4::boolean THEN photo_data_url ELSE NULL END
WHERE id = $1
AND photo_object_key IS NULL
AND photo_data_url LIKE 'data:image/%'`,
[bird.id, objectKey, parsedImage.contentType, keepDataUrl],
);
if (updateResult.rowCount !== 1) {
await deleteS3Object({ config: s3Config, objectKey });
skipped += 1;
console.warn(`Skipped bird ${bird.id} (${bird.name}): row changed before update.`);
continue;
}
migrated += 1;
console.log(`Migrated bird ${bird.id} (${bird.name}) -> ${objectKey}`);
} catch (error) {
failed += 1;
console.error(`Failed to migrate bird ${bird.id} (${bird.name}):`, error);
}
}
console.log(`Migration complete: migrated=${migrated}, skipped=${skipped}, failed=${failed}`);
if (failed > 0) {
process.exitCode = 1;
}
};
run()
.catch((error) => {
console.error('Bird photo migration failed:', error);
process.exitCode = 1;
})
.finally(async () => {
await db.close();
});
+83
View File
@@ -0,0 +1,83 @@
export type ImageStorageProvider = 'database' | 's3';
export type S3ImageStorageConfig = {
provider: 's3';
endpoint: string;
region: string;
bucket: string;
accessKeyId: string;
secretAccessKey: string;
publicBaseUrl: string | null;
keyPrefix: string;
};
const trimOptional = (value: string | undefined) => {
const trimmed = value?.trim();
return trimmed ? trimmed : null;
};
export const getImageStorageProvider = (): ImageStorageProvider =>
process.env.IMAGE_STORAGE_PROVIDER === 's3' ? 's3' : 'database';
export const getS3ImageStorageConfig = (): S3ImageStorageConfig | null => {
if (getImageStorageProvider() !== 's3') {
return null;
}
const endpoint = trimOptional(process.env.S3_ENDPOINT);
const region = trimOptional(process.env.S3_REGION);
const bucket = trimOptional(process.env.S3_BUCKET);
const accessKeyId = trimOptional(process.env.S3_ACCESS_KEY_ID);
const secretAccessKey = trimOptional(process.env.S3_SECRET_ACCESS_KEY);
if (!endpoint || !region || !bucket || !accessKeyId || !secretAccessKey) {
return null;
}
return {
provider: 's3',
endpoint,
region,
bucket,
accessKeyId,
secretAccessKey,
publicBaseUrl: trimOptional(process.env.S3_PUBLIC_BASE_URL),
keyPrefix: trimOptional(process.env.S3_KEY_PREFIX) ?? 'bird-photos',
};
};
export const isS3ImageStorageConfigured = () => getS3ImageStorageConfig() !== null;
export const buildBirdPhotoObjectKey = ({
workspaceId,
birdId,
extension,
now = new Date(),
}: {
workspaceId: number;
birdId: string;
extension: string;
now?: Date;
}) => {
const prefix = trimOptional(process.env.S3_KEY_PREFIX) ?? 'bird-photos';
const safeExtension = extension.replace(/^\./, '').toLowerCase() || 'bin';
const timestamp = now.toISOString().replace(/[:.]/g, '-');
return `${prefix}/workspace-${workspaceId}/${birdId}/${timestamp}.${safeExtension}`;
};
export const getImageExtensionFromContentType = (contentType: string) => {
switch (contentType.toLowerCase()) {
case 'image/jpeg':
case 'image/jpg':
return 'jpg';
case 'image/png':
return 'png';
case 'image/webp':
return 'webp';
case 'image/gif':
return 'gif';
default:
return 'bin';
}
};
+161
View File
@@ -0,0 +1,161 @@
import crypto from 'crypto';
import type { S3ImageStorageConfig } from './imageStorageConfig.js';
const awsDate = (date: Date) => date.toISOString().replace(/[:-]|\.\d{3}/g, '');
const shortDate = (date: Date) => date.toISOString().slice(0, 10).replace(/-/g, '');
const hmac = (key: crypto.BinaryLike, value: string) => crypto.createHmac('sha256', key).update(value).digest();
const sha256Hex = (value: crypto.BinaryLike) => crypto.createHash('sha256').update(value).digest('hex');
const encodeObjectKey = (key: string) => key.split('/').map(encodeURIComponent).join('/');
const encodeQueryValue = (value: string) => encodeURIComponent(value).replace(/[!'()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
const getSigningKey = (secretAccessKey: string, date: string, region: string) => {
const dateKey = hmac(`AWS4${secretAccessKey}`, date);
const regionKey = hmac(dateKey, region);
const serviceKey = hmac(regionKey, 's3');
return hmac(serviceKey, 'aws4_request');
};
const buildObjectUrl = (config: S3ImageStorageConfig, objectKey: string) => {
const endpoint = config.endpoint.replace(/\/+$/, '');
return new URL(`${endpoint}/${encodeURIComponent(config.bucket)}/${encodeObjectKey(objectKey)}`);
};
const signS3Request = ({
config,
method,
objectKey,
contentHash,
contentType,
now = new Date(),
}: {
config: S3ImageStorageConfig;
method: 'DELETE' | 'PUT';
objectKey: string;
contentHash: string;
contentType?: string;
now?: Date;
}) => {
const url = buildObjectUrl(config, objectKey);
const amzDate = awsDate(now);
const date = shortDate(now);
const credentialScope = `${date}/${config.region}/s3/aws4_request`;
const headers: Record<string, string> = {
host: url.host,
'x-amz-content-sha256': contentHash,
'x-amz-date': amzDate,
};
if (contentType) {
headers['content-type'] = contentType;
}
const signedHeaders = Object.keys(headers).sort().join(';');
const canonicalHeaders = Object.keys(headers)
.sort()
.map((key) => `${key}:${headers[key]}\n`)
.join('');
const canonicalRequest = [method, url.pathname, url.search.slice(1), canonicalHeaders, signedHeaders, contentHash].join('\n');
const stringToSign = ['AWS4-HMAC-SHA256', amzDate, credentialScope, sha256Hex(canonicalRequest)].join('\n');
const signature = crypto.createHmac('sha256', getSigningKey(config.secretAccessKey, date, config.region)).update(stringToSign).digest('hex');
return {
url,
headers: {
...headers,
Authorization: `AWS4-HMAC-SHA256 Credential=${config.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
},
};
};
export const getPublicObjectUrl = (config: S3ImageStorageConfig, objectKey: string) => {
if (config.publicBaseUrl) {
return `${config.publicBaseUrl.replace(/\/+$/, '')}/${encodeObjectKey(objectKey)}`;
}
return buildObjectUrl(config, objectKey).toString();
};
export const getSignedS3ObjectUrl = ({
config,
objectKey,
expiresInSeconds = 900,
now = new Date(),
}: {
config: S3ImageStorageConfig;
objectKey: string;
expiresInSeconds?: number;
now?: Date;
}) => {
const url = buildObjectUrl(config, objectKey);
const amzDate = awsDate(now);
const date = shortDate(now);
const credentialScope = `${date}/${config.region}/s3/aws4_request`;
const credential = `${config.accessKeyId}/${credentialScope}`;
const queryParams: Record<string, string> = {
'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
'X-Amz-Credential': credential,
'X-Amz-Date': amzDate,
'X-Amz-Expires': String(Math.min(Math.max(expiresInSeconds, 1), 604800)),
'X-Amz-SignedHeaders': 'host',
};
const canonicalQuery = Object.keys(queryParams)
.sort()
.map((key) => `${encodeQueryValue(key)}=${encodeQueryValue(queryParams[key])}`)
.join('&');
const canonicalHeaders = `host:${url.host}\n`;
const canonicalRequest = ['GET', url.pathname, canonicalQuery, canonicalHeaders, 'host', 'UNSIGNED-PAYLOAD'].join('\n');
const stringToSign = ['AWS4-HMAC-SHA256', amzDate, credentialScope, sha256Hex(canonicalRequest)].join('\n');
const signature = crypto.createHmac('sha256', getSigningKey(config.secretAccessKey, date, config.region)).update(stringToSign).digest('hex');
url.search = `${canonicalQuery}&X-Amz-Signature=${signature}`;
return url.toString();
};
export const putS3Object = async ({
config,
objectKey,
content,
contentType,
}: {
config: S3ImageStorageConfig;
objectKey: string;
content: Buffer;
contentType: string;
}) => {
const contentHash = sha256Hex(content);
const signed = signS3Request({ config, method: 'PUT', objectKey, contentHash, contentType });
const response = await fetch(signed.url, {
method: 'PUT',
headers: signed.headers,
body: content,
});
if (!response.ok) {
const errorText = await response.text().catch(() => '');
throw new Error(`Wasabi upload failed with ${response.status}${errorText ? `: ${errorText.slice(0, 300)}` : ''}`);
}
return getPublicObjectUrl(config, objectKey);
};
export const deleteS3Object = async ({
config,
objectKey,
}: {
config: S3ImageStorageConfig;
objectKey: string;
}) => {
const signed = signS3Request({ config, method: 'DELETE', objectKey, contentHash: sha256Hex('') });
const response = await fetch(signed.url, {
method: 'DELETE',
headers: signed.headers,
});
if (!response.ok && response.status !== 404) {
const errorText = await response.text().catch(() => '');
throw new Error(`Wasabi delete failed with ${response.status}${errorText ? `: ${errorText.slice(0, 300)}` : ''}`);
}
};
+132 -2
View File
@@ -1,21 +1,50 @@
export type WorkspaceType = 'standard' | 'rescue'; export type WorkspaceType = 'standard' | 'rescue';
export type WorkspaceRole = 'owner' | 'assistant' | 'caregiver' | 'viewer'; export type WorkspaceRole = 'owner' | 'assistant' | 'caregiver' | 'viewer';
export type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw'; export type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw' | 'household_hyacinth_macaw';
export type BillingInterval = 'monthly' | 'yearly'; export type BillingInterval = 'monthly' | 'yearly';
export type SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid' | 'none'; export type SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid' | 'none';
export type RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejected'; export type RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejected';
export type ProviderKey = 'google' | 'microsoft' | 'apple'; export type ProviderKey = 'google' | 'microsoft' | 'apple';
export type IntegrationTokenScope = 'read_only' | 'read_write'; export type IntegrationTokenScope = 'read_only' | 'read_write';
export type BirdGender = 'unknown' | 'male' | 'female'; export type BirdGender = 'unknown' | 'male' | 'female' | 'male_dna' | 'female_dna';
export type UserRow = { export type UserRow = {
id: string; id: string;
email: string; email: string;
password_hash: string | null; password_hash: string | null;
name: string; name: string;
education_opt_out?: boolean;
created_at: string; created_at: string;
}; };
export type DailyEducationQuestion = {
prompt: string;
options: string[];
correctAnswerIndex: number;
explanation: string | null;
};
export type DailyEducationRow = {
id: string;
publish_date: string;
fact: string;
quiz_questions: DailyEducationQuestion[];
created_by_user_id: string | null;
created_at: string;
updated_at: string;
};
export type EducationQuestionRow = {
id: string;
prompt: string;
options: string[];
correct_answer_index: number;
explanation: string | null;
created_by_user_id: string | null;
created_at: string;
updated_at: string;
};
export type WorkspaceRow = { export type WorkspaceRow = {
id: number; id: number;
name: string; name: string;
@@ -98,13 +127,27 @@ export type BirdRow = {
name: string; name: string;
tag_id: string | null; tag_id: string | null;
species: string; species: string;
motivators: string | null;
demotivators: string | null;
favorite_snack: string | null;
location_label: string | null;
location_details: Record<string, unknown> | null;
vet_clinic_name: string | null;
vet_clinic_address: string | null;
vet_account_number: string | null;
vet_doctor_name: string | null;
gender: BirdGender; gender: BirdGender;
date_of_birth: string | null; date_of_birth: string | null;
gotcha_day: string | null; gotcha_day: string | null;
chart_color: string; chart_color: string;
photo_data_url: string | null; photo_data_url: string | null;
photo_object_key: string | null;
photo_content_type: string | null;
photo_updated_at: string | null;
notify_on_dob: boolean; notify_on_dob: boolean;
notify_on_gotcha_day: boolean; notify_on_gotcha_day: boolean;
public_profile_code: string | null;
public_profile_enabled: boolean;
memorialized_at: string | null; memorialized_at: string | null;
memorialized_on: string | null; memorialized_on: string | null;
memorial_note: string | null; memorial_note: string | null;
@@ -150,6 +193,38 @@ export type PendingBirdTransferRow = {
created_at: string; created_at: string;
}; };
export type BirdTransferCodeRow = {
id: string;
code: string;
bird_id: string;
source_workspace_id: number;
requested_by_user_id: string;
completed_at: string | null;
completed_workspace_id: number | null;
revoked_at: string | null;
created_at: string;
};
export type BirdTimelineEventType = 'profile_created' | 'transferred' | 'location_updated' | 'owner_changed' | 'manual_note';
export type BirdTimelineEventRow = {
id: string;
bird_id: string;
event_type: BirdTimelineEventType;
from_workspace_id: number | null;
to_workspace_id: number | null;
from_workspace_name: string | null;
to_workspace_name: string | null;
from_owner_email: string | null;
to_owner_email: string | null;
location_label: string | null;
location_details: Record<string, unknown> | null;
note: string | null;
event_date: string;
created_by_user_id: string | null;
created_at: string;
};
export type WeightRow = { export type WeightRow = {
id: string; id: string;
bird_id: string; bird_id: string;
@@ -178,6 +253,7 @@ export type MedicationRow = {
start_date: string; start_date: string;
end_date: string | null; end_date: string | null;
notes: string | null; notes: string | null;
reminders_enabled: boolean;
}; };
export type MedicationDoseScheduleItem = { export type MedicationDoseScheduleItem = {
@@ -186,6 +262,33 @@ export type MedicationDoseScheduleItem = {
time: string; time: string;
}; };
export type MedicationReminderCandidateRow = BirdRow & {
workspace_name: string;
medication_id: string;
medication_name: string;
dosage: string;
frequency: string;
dose_schedule: MedicationDoseScheduleItem[];
route: string | null;
medication_start_date: string;
medication_end_date: string | null;
medication_notes: string | null;
scheduled_on: string;
administration_slot: string;
administration_label: string;
administration_time: string;
};
export type MedicationReminderDeliveryRow = {
id: string;
medication_id: string;
bird_id: string;
workspace_id: number;
scheduled_on: string;
administration_slot: string;
delivered_at: string;
};
export type MedicationAdministrationRow = { export type MedicationAdministrationRow = {
id: string; id: string;
medication_id: string; medication_id: string;
@@ -198,6 +301,33 @@ export type MedicationAdministrationRow = {
created_at: string; created_at: string;
}; };
export type FlockNoteRow = {
id: string;
workspace_id: number;
bird_id: string | null;
bird_name: string | null;
title: string;
body: string;
created_by_user_id: string | null;
created_by_name: string | null;
created_at: string;
updated_at: string;
};
export type AuditLogEntryRow = {
id: string;
workspace_id: number;
user_id: string | null;
actor_name: string | null;
actor_email: string | null;
action: string;
entity_type: string;
entity_id: string | null;
entity_name: string | null;
details: Record<string, unknown>;
created_at: string;
};
export type AuthContext = { export type AuthContext = {
user: UserRow; user: UserRow;
session: AuthSessionRow; session: AuthSessionRow;
+64 -1
View File
@@ -2,16 +2,36 @@ import { Worker } from 'bullmq';
import { ensureSchema } from './db/schema.js'; import { ensureSchema } from './db/schema.js';
import { db } from './db/client.js'; import { db } from './db/client.js';
import { runBirdMilestoneReminders, startBirdMilestoneReminderScheduler } from './app.js'; import {
runBirdMilestoneReminders,
runMedicationReminders,
startBirdMilestoneReminderScheduler,
startMedicationReminderScheduler,
} from './app.js';
import {
adoptionReportQueueName,
closeAdoptionReportQueue,
type AdoptionReportJobData,
type AdoptionReportJobResult,
} from './queues/adoptionReportQueue.js';
import { import {
birdMilestoneReminderQueueName, birdMilestoneReminderQueueName,
closeBirdMilestoneReminderQueue, closeBirdMilestoneReminderQueue,
type BirdMilestoneReminderJobData, type BirdMilestoneReminderJobData,
type BirdMilestoneReminderJobResult, type BirdMilestoneReminderJobResult,
} from './queues/birdMilestoneReminderQueue.js'; } from './queues/birdMilestoneReminderQueue.js';
import {
closeMedicationReminderQueue,
medicationReminderQueueName,
type MedicationReminderJobData,
type MedicationReminderJobResult,
} from './queues/medicationReminderQueue.js';
import { redisConnection } from './queues/redisConnection.js'; import { redisConnection } from './queues/redisConnection.js';
import { renderAdoptionReportForBird } from './reports/adoptionReportJob.js';
let birdMilestoneWorker: Worker<BirdMilestoneReminderJobData, BirdMilestoneReminderJobResult> | null = null; let birdMilestoneWorker: Worker<BirdMilestoneReminderJobData, BirdMilestoneReminderJobResult> | null = null;
let medicationReminderWorker: Worker<MedicationReminderJobData, MedicationReminderJobResult> | null = null;
let adoptionReportWorker: Worker<AdoptionReportJobData, AdoptionReportJobResult> | null = null;
const startWorker = async () => { const startWorker = async () => {
await ensureSchema(); await ensureSchema();
@@ -35,14 +55,57 @@ const startWorker = async () => {
console.error(`Bird milestone reminder job failed: id=${job?.id ?? 'unknown'}`, error); console.error(`Bird milestone reminder job failed: id=${job?.id ?? 'unknown'}`, error);
}); });
medicationReminderWorker = new Worker<MedicationReminderJobData, MedicationReminderJobResult>(
medicationReminderQueueName,
async (job) => {
const result = await runMedicationReminders(job.data.runDate, job.data.currentTime);
console.log(
`Medication reminder job completed for ${result.runDate} ${result.currentTime}: checked=${result.checked}, sent=${result.sent}, skipped=${result.skipped}, failed=${result.failed}`,
);
return result;
},
{
connection: redisConnection,
concurrency: 1,
},
);
medicationReminderWorker.on('failed', (job, error) => {
console.error(`Medication reminder job failed: id=${job?.id ?? 'unknown'}`, error);
});
adoptionReportWorker = new Worker<AdoptionReportJobData, AdoptionReportJobResult>(
adoptionReportQueueName,
async (job) => {
const pdf = await renderAdoptionReportForBird(job.data);
console.log(`Adoption report job completed: id=${job.id ?? 'unknown'}, birdId=${job.data.birdId}, bytes=${pdf.length}`);
return {
pdfBase64: pdf.toString('base64'),
};
},
{
connection: redisConnection,
concurrency: 1,
},
);
adoptionReportWorker.on('failed', (job, error) => {
console.error(`Adoption report job failed: id=${job?.id ?? 'unknown'}, birdId=${job?.data.birdId ?? 'unknown'}`, error);
});
startBirdMilestoneReminderScheduler(); startBirdMilestoneReminderScheduler();
startMedicationReminderScheduler();
console.log('FlockPal worker started.'); console.log('FlockPal worker started.');
}; };
const shutdown = async (signal: string) => { const shutdown = async (signal: string) => {
console.log(`FlockPal worker received ${signal}; shutting down.`); console.log(`FlockPal worker received ${signal}; shutting down.`);
await birdMilestoneWorker?.close(); await birdMilestoneWorker?.close();
await medicationReminderWorker?.close();
await adoptionReportWorker?.close();
await closeBirdMilestoneReminderQueue(); await closeBirdMilestoneReminderQueue();
await closeMedicationReminderQueue();
await closeAdoptionReportQueue();
await db.close(); await db.close();
process.exit(0); process.exit(0);
}; };
+29 -3
View File
@@ -43,12 +43,24 @@ services:
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} REDIS_URL: ${REDIS_URL:-redis://redis:6379}
ADOPTION_REPORT_RENDER_TIMEOUT_MS: ${ADOPTION_REPORT_RENDER_TIMEOUT_MS:-45000}
IMAGE_STORAGE_PROVIDER: ${IMAGE_STORAGE_PROVIDER:-database}
S3_ENDPOINT: ${S3_ENDPOINT:-}
S3_REGION: ${S3_REGION:-}
S3_BUCKET: ${S3_BUCKET:-}
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
S3_PUBLIC_BASE_URL: ${S3_PUBLIC_BASE_URL:-}
S3_KEY_PREFIX: ${S3_KEY_PREFIX:-bird-photos}
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
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}
MAPBOX_ACCESS_TOKEN: ${MAPBOX_ACCESS_TOKEN:-}
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} 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}
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_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:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
@@ -64,9 +76,12 @@ services:
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK:-} STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK:-}
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY:-} STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY:-}
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY:-} STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY:-}
STRIPE_PRICE_HOUSEHOLD_MACAW: ${STRIPE_PRICE_HOUSEHOLD_MACAW:-} STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY: ${STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY:-}
STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY:-} STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_MONTHLY:-}
STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY:-} STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_YEARLY:-}
STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW: ${STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW:-}
STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_MONTHLY:-}
STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_YEARLY:-}
STRIPE_CHECKOUT_SUCCESS_URL: ${STRIPE_CHECKOUT_SUCCESS_URL:-${FRONTEND_URL}/?billing=success} STRIPE_CHECKOUT_SUCCESS_URL: ${STRIPE_CHECKOUT_SUCCESS_URL:-${FRONTEND_URL}/?billing=success}
STRIPE_CHECKOUT_CANCEL_URL: ${STRIPE_CHECKOUT_CANCEL_URL:-${FRONTEND_URL}/?billing=cancelled} STRIPE_CHECKOUT_CANCEL_URL: ${STRIPE_CHECKOUT_CANCEL_URL:-${FRONTEND_URL}/?billing=cancelled}
STRIPE_PORTAL_RETURN_URL: ${STRIPE_PORTAL_RETURN_URL:-${FRONTEND_URL}/?billing=portal} STRIPE_PORTAL_RETURN_URL: ${STRIPE_PORTAL_RETURN_URL:-${FRONTEND_URL}/?billing=portal}
@@ -111,12 +126,23 @@ services:
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} REDIS_URL: ${REDIS_URL:-redis://redis:6379}
IMAGE_STORAGE_PROVIDER: ${IMAGE_STORAGE_PROVIDER:-database}
S3_ENDPOINT: ${S3_ENDPOINT:-}
S3_REGION: ${S3_REGION:-}
S3_BUCKET: ${S3_BUCKET:-}
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
S3_PUBLIC_BASE_URL: ${S3_PUBLIC_BASE_URL:-}
S3_KEY_PREFIX: ${S3_KEY_PREFIX:-bird-photos}
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
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}
MAPBOX_ACCESS_TOKEN: ${MAPBOX_ACCESS_TOKEN:-}
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} 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}
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_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}
SMTP_HOST: ${SMTP_HOST:-} SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587} SMTP_PORT: ${SMTP_PORT:-587}
+30 -4
View File
@@ -41,12 +41,24 @@ services:
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} REDIS_URL: ${REDIS_URL:-redis://redis:6379}
ADOPTION_REPORT_RENDER_TIMEOUT_MS: ${ADOPTION_REPORT_RENDER_TIMEOUT_MS:-45000}
IMAGE_STORAGE_PROVIDER: ${IMAGE_STORAGE_PROVIDER:-database}
S3_ENDPOINT: ${S3_ENDPOINT:-}
S3_REGION: ${S3_REGION:-}
S3_BUCKET: ${S3_BUCKET:-}
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
S3_PUBLIC_BASE_URL: ${S3_PUBLIC_BASE_URL:-}
S3_KEY_PREFIX: ${S3_KEY_PREFIX:-bird-photos}
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
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}
MAPBOX_ACCESS_TOKEN: ${MAPBOX_ACCESS_TOKEN:-}
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} 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}
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_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:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
@@ -62,9 +74,12 @@ services:
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK:-} STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK:-}
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY:-} STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY:-}
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY:-} STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY:-}
STRIPE_PRICE_HOUSEHOLD_MACAW: ${STRIPE_PRICE_HOUSEHOLD_MACAW:-} STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY: ${STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY:-}
STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY:-} STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_MONTHLY:-}
STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY:-} STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_YEARLY:-}
STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW: ${STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW:-}
STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_MONTHLY:-}
STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_YEARLY:-}
STRIPE_CHECKOUT_SUCCESS_URL: ${STRIPE_CHECKOUT_SUCCESS_URL:-http://localhost:3000/?billing=success} STRIPE_CHECKOUT_SUCCESS_URL: ${STRIPE_CHECKOUT_SUCCESS_URL:-http://localhost:3000/?billing=success}
STRIPE_CHECKOUT_CANCEL_URL: ${STRIPE_CHECKOUT_CANCEL_URL:-http://localhost:3000/?billing=cancelled} STRIPE_CHECKOUT_CANCEL_URL: ${STRIPE_CHECKOUT_CANCEL_URL:-http://localhost:3000/?billing=cancelled}
STRIPE_PORTAL_RETURN_URL: ${STRIPE_PORTAL_RETURN_URL:-http://localhost:3000/?billing=portal} STRIPE_PORTAL_RETURN_URL: ${STRIPE_PORTAL_RETURN_URL:-http://localhost:3000/?billing=portal}
@@ -104,12 +119,23 @@ services:
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} REDIS_URL: ${REDIS_URL:-redis://redis:6379}
IMAGE_STORAGE_PROVIDER: ${IMAGE_STORAGE_PROVIDER:-database}
S3_ENDPOINT: ${S3_ENDPOINT:-}
S3_REGION: ${S3_REGION:-}
S3_BUCKET: ${S3_BUCKET:-}
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
S3_PUBLIC_BASE_URL: ${S3_PUBLIC_BASE_URL:-}
S3_KEY_PREFIX: ${S3_KEY_PREFIX:-bird-photos}
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
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}
MAPBOX_ACCESS_TOKEN: ${MAPBOX_ACCESS_TOKEN:-}
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} 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}
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_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}
SMTP_HOST: ${SMTP_HOST:-} SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587} SMTP_PORT: ${SMTP_PORT:-587}
@@ -136,7 +162,7 @@ services:
dockerfile: Dockerfile.dev dockerfile: Dockerfile.dev
container_name: flockpal-frontend container_name: flockpal-frontend
environment: environment:
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-http://localhost:5000/api} VITE_API_BASE_URL: ${VITE_API_BASE_URL:-/api}
depends_on: depends_on:
- backend - backend
ports: ports:
+47 -5
View File
@@ -208,7 +208,11 @@ Role requirements are called out per endpoint below. If the signed-in member lac
"name": "Kiwi", "name": "Kiwi",
"tagId": "FP-001", "tagId": "FP-001",
"species": "Cockatiel", "species": "Cockatiel",
"gender": "female", "vetClinicName": "Avian Care Center",
"vetClinicAddress": "123 Feather Lane, Raleigh, NC",
"vetAccountNumber": "FP-1001",
"vetDoctorName": "Dr. Rivera",
"gender": "female_dna",
"dateOfBirth": "2023-05-10", "dateOfBirth": "2023-05-10",
"gotchaDay": "2023-08-21", "gotchaDay": "2023-08-21",
"chartColor": "#cb3a35", "chartColor": "#cb3a35",
@@ -295,7 +299,7 @@ Role requirements are called out per endpoint below. If the signed-in member lac
- Dates use `YYYY-MM-DD` - Dates use `YYYY-MM-DD`
- `workspaceType` is `standard` or `rescue` - `workspaceType` is `standard` or `rescue`
- member `role` is `owner`, `assistant`, `caregiver`, or `viewer` - member `role` is `owner`, `assistant`, `caregiver`, or `viewer`
- bird `gender` is `unknown`, `male`, or `female` - bird `gender` is `unknown`, `male`, `female`, `male_dna`, or `female_dna`; `male` and `female` indicate assumed sex
- bird `chartColor` must be a `#RRGGBB` hex color - bird `chartColor` must be a `#RRGGBB` hex color
- `photoDataUrl` must be a base64 `data:image/...` URL - `photoDataUrl` must be a base64 `data:image/...` URL
- `weightGrams` must be a positive number up to `10000` - `weightGrams` must be a positive number up to `10000`
@@ -653,7 +657,7 @@ Request body:
Notes: Notes:
- `workspaceType` must be `standard` or `rescue` - `workspaceType` must be `standard` or `rescue`
- `billingPlan` may be `household_basic`, `household_plus`, or `household_macaw` - `billingPlan` may be `household_basic`, `household_plus`, `household_macaw`, or `household_hyacinth_macaw`
- rescue workspaces are forced to `rescue_free` - rescue workspaces are forced to `rescue_free`
Response `201`: Response `201`:
@@ -793,7 +797,11 @@ Request body:
"name": "Kiwi", "name": "Kiwi",
"tagId": "FP-001", "tagId": "FP-001",
"species": "Cockatiel", "species": "Cockatiel",
"gender": "female", "vetClinicName": "Avian Care Center",
"vetClinicAddress": "123 Feather Lane, Raleigh, NC",
"vetAccountNumber": "FP-1001",
"vetDoctorName": "Dr. Rivera",
"gender": "female_dna",
"dateOfBirth": "2023-05-10", "dateOfBirth": "2023-05-10",
"gotchaDay": "2023-08-21", "gotchaDay": "2023-08-21",
"chartColor": "#cb3a35", "chartColor": "#cb3a35",
@@ -805,7 +813,7 @@ Request body:
Notes: Notes:
- `dateOfBirth`, `gotchaDay`, and `photoDataUrl` may be omitted or sent as empty strings - `dateOfBirth`, `gotchaDay`, `photoDataUrl`, and veterinary info fields may be omitted or sent as empty strings
- `chartColor` defaults to `#cb3a35` - `chartColor` defaults to `#cb3a35`
Response `201`: Response `201`:
@@ -889,6 +897,40 @@ Possible errors:
- `409` if that owner email owns more than one receiving flock - `409` if that owner email owns more than one receiving flock
- `409` if the destination flock already has a bird using the same `tagId` - `409` if the destination flock already has a bird using the same `tagId`
#### `POST /api/birds/:birdId/transfer-code`
Requires a browser session, write access, and role `owner` or `assistant`. Creates a unique transfer code for a bird. Creating a new open code for the same bird revokes earlier unused codes for that bird.
Response `201`:
```json
{
"transferCode": {
"code": "secure-code",
"bird": {}
}
}
```
#### `POST /api/bird-transfer-codes/:code/accept`
Requires a browser session, write access, and role `owner` or `assistant`. Accepts a transfer code into the signed-in user's active flock.
Response `200`:
```json
{
"bird": {},
"sourceWorkspaceName": "Previous Flock",
"workspace": {}
}
```
Possible errors:
- `404` if the code does not exist, was revoked, was already used, or the bird is no longer available
- `409` if the bird is already in the active flock or the active flock already has the same `tagId`
#### `DELETE /api/birds/:birdId` #### `DELETE /api/birds/:birdId`
Requires auth with write access and role `owner`, `assistant`, or `caregiver`. Deletes a bird. Requires auth with write access and role `owner`, `assistant`, or `caregiver`. Deletes a bird.
+1
View File
@@ -7,6 +7,7 @@ RUN npm ci
COPY tsconfig*.json ./ COPY tsconfig*.json ./
COPY vite.config.ts ./ COPY vite.config.ts ./
COPY index.html ./ COPY index.html ./
COPY public ./public
COPY src ./src COPY src ./src
RUN npm run build RUN npm run build
+1
View File
@@ -5,6 +5,7 @@ RUN npm install
COPY tsconfig*.json ./ COPY tsconfig*.json ./
COPY vite.config.ts ./ COPY vite.config.ts ./
COPY index.html ./ COPY index.html ./
COPY public ./public
COPY src ./src COPY src ./src
EXPOSE 3000 EXPOSE 3000
CMD ["npm", "run", "dev", "--", "--host"] CMD ["npm", "run", "dev", "--", "--host"]
+497 -1
View File
@@ -8,8 +8,11 @@
"name": "flockpal-frontend", "name": "flockpal-frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@types/qrcode": "^1.5.6",
"qrcode": "^1.5.4",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1" "react-dom": "18.3.1",
"read-excel-file": "^9.0.9"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "18.3.12", "@types/react": "18.3.12",
@@ -1144,6 +1147,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": {
"version": "25.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/@types/prop-types": { "node_modules/@types/prop-types": {
"version": "15.7.15", "version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -1151,6 +1163,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "18.3.12", "version": "18.3.12",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz",
@@ -1192,6 +1213,39 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0"
} }
}, },
"node_modules/@xmldom/xmldom": {
"version": "0.9.10",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz",
"integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==",
"license": "MIT",
"engines": {
"node": ">=14.6"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.10.16", "version": "2.10.16",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.16.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.16.tgz",
@@ -1205,6 +1259,12 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/bluebird": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
"license": "MIT"
},
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.28.2", "version": "4.28.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
@@ -1239,6 +1299,15 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
} }
}, },
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001786", "version": "1.0.30001786",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001786.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001786.tgz",
@@ -1260,6 +1329,35 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/convert-source-map": { "node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1267,6 +1365,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -1292,6 +1396,30 @@
} }
} }
}, },
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/duplexer2": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz",
"integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==",
"license": "BSD-3-Clause",
"dependencies": {
"readable-stream": "^2.0.2"
}
},
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.331", "version": "1.5.331",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz",
@@ -1299,6 +1427,12 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.21.5", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -1348,6 +1482,39 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/fs-extra": {
"version": "11.3.5",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz",
"integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=14.14"
}
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1373,6 +1540,42 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1405,6 +1608,30 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/jsonfile": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/loose-envify": { "node_modules/loose-envify": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -1453,6 +1680,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
} }
}, },
"node_modules/node-int64": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
"license": "MIT"
},
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.37", "version": "2.0.37",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz",
@@ -1460,6 +1693,51 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1467,6 +1745,15 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.8", "version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
@@ -1496,6 +1783,29 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/react": { "node_modules/react": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -1531,6 +1841,50 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/read-excel-file": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/read-excel-file/-/read-excel-file-9.0.9.tgz",
"integrity": "sha512-FWwC3IypIQDVPTtO4pz0Sq6An7lQI17pXqCusaTX8yi3p9CCRtXx/SI3BtcPSTaLhwcwr9mI+KXSa/dWMmnvjQ==",
"license": "MIT",
"dependencies": {
"@xmldom/xmldom": "^0.9.9",
"fflate": "^0.8.2",
"unzipper": "^0.12.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.60.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
@@ -1576,6 +1930,12 @@
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.23.2", "version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@@ -1595,6 +1955,12 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1605,6 +1971,41 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.6.3", "version": "5.6.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
@@ -1619,6 +2020,34 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"license": "MIT"
},
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/unzipper": {
"version": "0.12.3",
"resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.3.tgz",
"integrity": "sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==",
"license": "MIT",
"dependencies": {
"bluebird": "~3.7.2",
"duplexer2": "~0.1.4",
"fs-extra": "^11.2.0",
"graceful-fs": "^4.2.2",
"node-int64": "^0.4.0"
}
},
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -1650,6 +2079,12 @@
"browserslist": ">= 4.21.0" "browserslist": ">= 4.21.0"
} }
}, },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/vite": { "node_modules/vite": {
"version": "5.4.10", "version": "5.4.10",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.10.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.10.tgz",
@@ -1710,12 +2145,73 @@
} }
} }
}, },
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
} }
} }
} }
+4 -1
View File
@@ -9,8 +9,11 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@types/qrcode": "^1.5.6",
"qrcode": "^1.5.4",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1" "react-dom": "18.3.1",
"read-excel-file": "^9.0.9"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "18.3.12", "@types/react": "18.3.12",
+4403 -803
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

+1117 -19
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -5,5 +5,11 @@ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
port: 3000, port: 3000,
proxy: {
'/api': {
target: 'http://backend:5000',
changeOrigin: true,
},
},
}, },
}); });
+4
View File
@@ -1,4 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
if [ -z "${BASH_VERSION:-}" ]; then
exec bash "$0" "$@"
fi
set -euo pipefail set -euo pipefail
compose_file="${COMPOSE_FILE:-docker-compose.prod.yml}" compose_file="${COMPOSE_FILE:-docker-compose.prod.yml}"
+4
View File
@@ -1,4 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
if [ -z "${BASH_VERSION:-}" ]; then
exec bash "$0" "$@"
fi
set -euo pipefail set -euo pipefail
if [[ $# -ne 1 ]]; then if [[ $# -ne 1 ]]; then