31 Commits

Author SHA1 Message Date
Corey Blais 52008f5b43 Fix adoption report photos and section order
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 2m23s
2026-06-03 10:05:48 -04:00
Corey Blais 5b57cdd6bf Fix adoption report QR and chart layout 2026-06-03 10:05:48 -04:00
Corey Blais 60eadf0847 Refine adoption report header layout 2026-06-03 10:05:48 -04:00
Corey Blais 682ccfd41f Generate adoption reports as PDFs 2026-06-03 10:05:48 -04:00
Corey Blais 59c6b19ad6 fixing analytics icon pt2
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 2m15s
2026-06-02 12:55:35 -04:00
Corey Blais aa1a4cf6ff fixing analytics icon 2026-06-02 12:55:35 -04:00
Corey Blais 5f0fad3cbb Updated adoption report 2026-06-02 12:55:35 -04:00
Corey Blais 545fae59b2 Added adoption report and transfer code 2026-06-02 12:55:35 -04:00
Corey Blais d748d2db21 Added vet info
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 2m14s
2026-06-01 15:20:47 -04:00
blaisadmin 095c91e56d Merge branch 'release/flock-member-notes-audit'
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 2m16s
2026-05-30 22:50:54 -04:00
blaisadmin f2017068d5 Auto deploy production on main merge 2026-05-30 22:47:11 -04:00
blaisadmin c9702495a3 Add flock member notes and audit tabs 2026-05-30 22:46:31 -04:00
blaisadmin e965cb55ef Revert education feature from main 2026-05-30 15:50:23 -04:00
blaisadmin 505a9b8496 Added backend limits 2026-05-30 15:43:38 -04:00
blaisadmin c6dc5b22b8 Merge dev into main 2026-05-30 15:29:48 -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 f16e88e2f0 Validate builds before deploy 2026-05-24 21:48:42 -04:00
blaisadmin 016bc187d4 Run deploy compose from host repo paths 2026-05-24 11:33:58 -04:00
blaisadmin 104f01f75d Use local mounts for production deploy workflow 2026-05-23 18:38:49 -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 568aee3e70 Adding gitea action 2026-05-23 16:32:59 -04:00
21 changed files with 3585 additions and 1369 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
+6 -3
View File
@@ -29,9 +29,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
+88
View File
@@ -0,0 +1,88 @@
name: Deploy
on:
push:
branches:
- main
- dev
- develop
workflow_dispatch:
jobs:
deploy-dev:
if: ${{ github.event_name == 'push' && (github.ref_name == 'dev' || github.ref_name == 'develop') }}
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' || (github.event_name == 'push' && github.ref_name == 'main') }}
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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

+5 -3
View File
@@ -203,8 +203,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`
@@ -221,7 +223,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

+529
View File
@@ -17,7 +17,9 @@
"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",
"stripe": "^22.0.2", "stripe": "^22.0.2",
"zod": "3.24.1" "zod": "3.24.1"
}, },
@@ -26,7 +28,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"
} }
@@ -523,6 +527,39 @@
"win32" "win32"
] ]
}, },
"node_modules/@noble/ciphers": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@types/body-parser": { "node_modules/@types/body-parser": {
"version": "1.19.6", "version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -615,6 +652,16 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/pdfkit": {
"version": "0.17.6",
"resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz",
"integrity": "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/pg": { "node_modules/@types/pg": {
"version": "8.11.10", "version": "8.11.10",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.10.tgz", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.10.tgz",
@@ -627,6 +674,16 @@
"pg-types": "^4.0.1" "pg-types": "^4.0.1"
} }
}, },
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/qs": { "node_modules/@types/qs": {
"version": "6.15.0", "version": "6.15.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
@@ -675,12 +732,56 @@
"node": ">= 0.6" "node": ">= 0.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/array-flatten": { "node_modules/array-flatten": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/basic-auth": { "node_modules/basic-auth": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
@@ -723,6 +824,24 @@
"npm": "1.2.8000 || >= 1.4.16" "npm": "1.2.8000 || >= 1.4.16"
} }
}, },
"node_modules/brotli": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.1.2"
}
},
"node_modules/browserify-zlib": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
"integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
"license": "MIT",
"dependencies": {
"pako": "~1.0.5"
}
},
"node_modules/bullmq": { "node_modules/bullmq": {
"version": "5.76.4", "version": "5.76.4",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.76.4.tgz", "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.76.4.tgz",
@@ -778,6 +897,35 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"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/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/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/cluster-key-slot": { "node_modules/cluster-key-slot": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
@@ -787,6 +935,24 @@
"node": ">=0.10.0" "node": ">=0.10.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/content-disposition": { "node_modules/content-disposition": {
"version": "0.5.4", "version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -857,6 +1023,15 @@
"ms": "2.0.0" "ms": "2.0.0"
} }
}, },
"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/denque": { "node_modules/denque": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
@@ -895,6 +1070,18 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/dfa": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
"license": "MIT"
},
"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/dotenv": { "node_modules/dotenv": {
"version": "16.4.5", "version": "16.4.5",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz",
@@ -927,6 +1114,12 @@
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT" "license": "MIT"
}, },
"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/encodeurl": { "node_modules/encodeurl": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
@@ -1082,6 +1275,12 @@
"express": "^4.11 || 5 || ^5.0.0-beta.1" "express": "^4.11 || 5 || ^5.0.0-beta.1"
} }
}, },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/finalhandler": { "node_modules/finalhandler": {
"version": "1.3.1", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
@@ -1100,6 +1299,36 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"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/fontkit": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
"license": "MIT",
"dependencies": {
"@swc/helpers": "^0.5.12",
"brotli": "^1.3.2",
"clone": "^2.1.2",
"dfa": "^1.2.0",
"fast-deep-equal": "^3.1.3",
"restructure": "^3.0.0",
"tiny-inflate": "^1.0.3",
"unicode-properties": "^1.4.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/forwarded": { "node_modules/forwarded": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -1142,6 +1371,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"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/get-intrinsic": { "node_modules/get-intrinsic": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -1327,6 +1565,52 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"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/js-md5": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
"integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==",
"license": "MIT"
},
"node_modules/linebreak": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
"license": "MIT",
"dependencies": {
"base64-js": "0.0.8",
"unicode-trie": "^2.0.0"
}
},
"node_modules/linebreak/node_modules/base64-js": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"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/lodash.defaults": { "node_modules/lodash.defaults": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
@@ -1570,6 +1854,48 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"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/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parseurl": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -1579,12 +1905,35 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"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/path-to-regexp": { "node_modules/path-to-regexp": {
"version": "0.1.12", "version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/pdfkit": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.18.0.tgz",
"integrity": "sha512-NvUwSDZ0eYEzqAiWwVQkRkjYUkZ48kcsHuCO31ykqPPIVkwoSDjDGiwIgHHNtsiwls3z3P/zy4q00hl2chg2Ug==",
"license": "MIT",
"dependencies": {
"@noble/ciphers": "^1.0.0",
"@noble/hashes": "^1.6.0",
"fontkit": "^2.0.4",
"js-md5": "^0.8.3",
"linebreak": "^1.1.0",
"png-js": "^1.0.0"
}
},
"node_modules/pg": { "node_modules/pg": {
"version": "8.13.1", "version": "8.13.1",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.13.1.tgz", "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.1.tgz",
@@ -1742,6 +2091,23 @@
"split2": "^4.1.0" "split2": "^4.1.0"
} }
}, },
"node_modules/png-js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz",
"integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==",
"dependencies": {
"browserify-zlib": "^0.2.0"
}
},
"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/postgres-array": { "node_modules/postgres-array": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz",
@@ -1805,6 +2171,23 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"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/qs": { "node_modules/qs": {
"version": "6.13.0", "version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
@@ -1865,6 +2248,21 @@
"node": ">=4" "node": ">=4"
} }
}, },
"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/resolve-pkg-maps": { "node_modules/resolve-pkg-maps": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
@@ -1875,6 +2273,12 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
} }
}, },
"node_modules/restructure": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
"license": "MIT"
},
"node_modules/safe-buffer": { "node_modules/safe-buffer": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -1967,6 +2371,12 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"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/setprototypeof": { "node_modules/setprototypeof": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -2069,6 +2479,32 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"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/stripe": { "node_modules/stripe": {
"version": "22.0.2", "version": "22.0.2",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-22.0.2.tgz", "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.0.2.tgz",
@@ -2086,6 +2522,12 @@
} }
} }
}, },
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT"
},
"node_modules/toidentifier": { "node_modules/toidentifier": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -2154,6 +2596,32 @@
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/unicode-properties": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/unicode-trie": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"license": "MIT",
"dependencies": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
},
"node_modules/unicode-trie/node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
"license": "MIT"
},
"node_modules/unpipe": { "node_modules/unpipe": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -2181,6 +2649,26 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"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/xtend": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
@@ -2190,6 +2678,47 @@
"node": ">=0.4" "node": ">=0.4"
} }
}, },
"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/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"
}
},
"node_modules/zod": { "node_modules/zod": {
"version": "3.24.1", "version": "3.24.1",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz",
+4
View File
@@ -23,7 +23,9 @@
"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",
"stripe": "^22.0.2", "stripe": "^22.0.2",
"zod": "3.24.1" "zod": "3.24.1"
}, },
@@ -32,7 +34,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"
} }
+544 -229
View File
File diff suppressed because it is too large Load Diff
+76 -42
View File
@@ -30,9 +30,6 @@ 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',
@@ -142,37 +139,6 @@ 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,
@@ -249,6 +215,10 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
motivators VARCHAR(1000), motivators VARCHAR(1000),
demotivators VARCHAR(1000), demotivators VARCHAR(1000),
favorite_snack VARCHAR(160), favorite_snack VARCHAR(160),
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,
@@ -273,6 +243,10 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ADD COLUMN IF NOT EXISTS motivators VARCHAR(1000), ADD COLUMN IF NOT EXISTS motivators VARCHAR(1000),
ADD COLUMN IF NOT EXISTS demotivators VARCHAR(1000), ADD COLUMN IF NOT EXISTS demotivators VARCHAR(1000),
ADD COLUMN IF NOT EXISTS favorite_snack VARCHAR(160), ADD COLUMN IF NOT EXISTS favorite_snack VARCHAR(160),
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,
@@ -318,8 +292,8 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
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');
@@ -347,8 +321,8 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ON birds (public_profile_code) ON birds (public_profile_code)
WHERE public_profile_code IS NOT NULL; 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,
source_workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, source_workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
destination_owner_email VARCHAR(255) NOT NULL, destination_owner_email VARCHAR(255) NOT NULL,
@@ -368,11 +342,71 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ON pending_bird_transfers (LOWER(destination_owner_email), created_at DESC) ON pending_bird_transfers (LOWER(destination_owner_email), created_at DESC)
WHERE completed_at IS NULL; WHERE completed_at IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_bird_transfers_open_bird CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_bird_transfers_open_bird
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 weight_records ( 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 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 (
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,
weight_grams NUMERIC(8, 2) NOT NULL CHECK (weight_grams > 0), weight_grams NUMERIC(8, 2) NOT NULL CHECK (weight_grams > 0),
+416
View File
@@ -0,0 +1,416 @@
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') {
return 'Female';
}
if (value === 'male') {
return 'Male';
}
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;
};
+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;
};
+141 -21
View File
@@ -5,6 +5,7 @@ import type {
BirdMilestoneReminderDeliveryRow, BirdMilestoneReminderDeliveryRow,
BirdMilestoneReminderType, BirdMilestoneReminderType,
BirdRow, BirdRow,
BirdTransferCodeRow,
LostBirdMatchRow, LostBirdMatchRow,
MedicationAdministrationRow, MedicationAdministrationRow,
MedicationDoseScheduleItem, MedicationDoseScheduleItem,
@@ -23,6 +24,10 @@ const birdSelectFields = `
birds.motivators, birds.motivators,
birds.demotivators, birds.demotivators,
birds.favorite_snack, birds.favorite_snack,
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,
@@ -287,6 +292,10 @@ export const createBird = async ({
motivators, motivators,
demotivators, demotivators,
favoriteSnack, favoriteSnack,
vetClinicName = null,
vetClinicAddress = null,
vetAccountNumber = null,
vetDoctorName = null,
gender, gender,
dateOfBirth, dateOfBirth,
gotchaDay, gotchaDay,
@@ -308,6 +317,10 @@ export const createBird = async ({
motivators: string | null; motivators: string | null;
demotivators: string | null; demotivators: string | null;
favoriteSnack: string | null; favoriteSnack: string | 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;
@@ -322,9 +335,9 @@ export const createBird = async ({
publicProfileEnabled?: boolean; publicProfileEnabled?: boolean;
}) => { }) => {
const result = await db.query<BirdRow>( const result = await db.query<BirdRow>(
`INSERT INTO birds (id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, 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) `INSERT INTO birds (id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, 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 (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) 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)
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, 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`, RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, 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`,
[ [
birdId ?? null, birdId ?? null,
workspaceId, workspaceId,
@@ -334,6 +347,10 @@ export const createBird = async ({
motivators, motivators,
demotivators, demotivators,
favoriteSnack, favoriteSnack,
vetClinicName,
vetClinicAddress,
vetAccountNumber,
vetDoctorName,
gender, gender,
dateOfBirth, dateOfBirth,
gotchaDay, gotchaDay,
@@ -361,6 +378,10 @@ export const updateBird = async ({
motivators, motivators,
demotivators, demotivators,
favoriteSnack, favoriteSnack,
vetClinicName,
vetClinicAddress,
vetAccountNumber,
vetDoctorName,
gender, gender,
dateOfBirth, dateOfBirth,
gotchaDay, gotchaDay,
@@ -382,6 +403,10 @@ export const updateBird = async ({
motivators: string | null; motivators: string | null;
demotivators: string | null; demotivators: string | null;
favoriteSnack: string | null; favoriteSnack: string | 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;
@@ -403,22 +428,26 @@ export const updateBird = async ({
motivators = $5, motivators = $5,
demotivators = $6, demotivators = $6,
favorite_snack = $7, favorite_snack = $7,
gender = $8, vet_clinic_name = $8,
date_of_birth = $9, vet_clinic_address = $9,
gotcha_day = $10, vet_account_number = $10,
chart_color = $11, vet_doctor_name = $11,
photo_data_url = $12, gender = $12,
photo_object_key = $13, date_of_birth = $13,
photo_content_type = $14, gotcha_day = $14,
photo_updated_at = $15, chart_color = $15,
notify_on_dob = $16, photo_data_url = $16,
notify_on_gotcha_day = $17, photo_object_key = $17,
public_profile_code = $18, photo_content_type = $18,
public_profile_enabled = $19 photo_updated_at = $19,
notify_on_dob = $20,
notify_on_gotcha_day = $21,
public_profile_code = $22,
public_profile_enabled = $23
WHERE id = $1 WHERE id = $1
AND workspace_id = $20 AND workspace_id = $24
AND memorialized_at IS NULL AND memorialized_at IS NULL
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, 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, RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, 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
@@ -441,6 +470,10 @@ export const updateBird = async ({
motivators, motivators,
demotivators, demotivators,
favoriteSnack, favoriteSnack,
vetClinicName,
vetClinicAddress,
vetAccountNumber,
vetDoctorName,
gender, gender,
dateOfBirth, dateOfBirth,
gotchaDay, gotchaDay,
@@ -482,7 +515,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, photo_object_key, photo_content_type, photo_updated_at, 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, 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
@@ -518,7 +551,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, photo_object_key, photo_content_type, photo_updated_at, 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, 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
@@ -558,7 +591,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, photo_object_key, photo_content_type, photo_updated_at, 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, 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
@@ -659,7 +692,7 @@ export const completePendingBirdTransfersForOwner = async (ownerEmail: string, t
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.';
@@ -670,6 +703,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
@@ -1,153 +0,0 @@
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);
};
+44 -30
View File
@@ -1,6 +1,6 @@
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';
@@ -13,38 +13,9 @@ export type UserRow = {
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;
@@ -130,6 +101,10 @@ export type BirdRow = {
motivators: string | null; motivators: string | null;
demotivators: string | null; demotivators: string | null;
favorite_snack: string | null; favorite_snack: string | 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;
@@ -187,6 +162,18 @@ 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 WeightRow = { export type WeightRow = {
id: string; id: string;
bird_id: string; bird_id: string;
@@ -235,6 +222,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;
+6 -3
View File
@@ -73,9 +73,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}
+6 -3
View File
@@ -71,9 +71,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}
+44 -2
View File
@@ -208,6 +208,10 @@ 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",
"vetClinicName": "Avian Care Center",
"vetClinicAddress": "123 Feather Lane, Raleigh, NC",
"vetAccountNumber": "FP-1001",
"vetDoctorName": "Dr. Rivera",
"gender": "female", "gender": "female",
"dateOfBirth": "2023-05-10", "dateOfBirth": "2023-05-10",
"gotchaDay": "2023-08-21", "gotchaDay": "2023-08-21",
@@ -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,6 +797,10 @@ Request body:
"name": "Kiwi", "name": "Kiwi",
"tagId": "FP-001", "tagId": "FP-001",
"species": "Cockatiel", "species": "Cockatiel",
"vetClinicName": "Avian Care Center",
"vetClinicAddress": "123 Feather Lane, Raleigh, NC",
"vetAccountNumber": "FP-1001",
"vetDoctorName": "Dr. Rivera",
"gender": "female", "gender": "female",
"dateOfBirth": "2023-05-10", "dateOfBirth": "2023-05-10",
"gotchaDay": "2023-08-21", "gotchaDay": "2023-08-21",
@@ -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.
+1346 -754
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

+181 -126
View File
@@ -713,6 +713,11 @@ textarea {
gap: 1.5rem; gap: 1.5rem;
} }
.bird-detail-panel {
margin-right: 3rem;
position: relative;
}
.flock-detail-column { .flock-detail-column {
display: grid; display: grid;
gap: 1.5rem; gap: 1.5rem;
@@ -745,123 +750,6 @@ textarea {
align-content: start; align-content: start;
} }
.daily-education-panel,
.daily-quiz,
.quiz-options,
.education-question-editor {
display: grid;
gap: 1rem;
}
.daily-education-panel.condensed {
gap: 0.35rem;
padding-block: 1rem;
}
.daily-education-panel.condensed .panel-header {
margin-bottom: 0;
}
.daily-education-teaser {
margin: 0;
}
.daily-fact {
margin: 0;
padding: 1rem;
border-left: 4px solid var(--accent-gold);
border-radius: 0 8px 8px 0;
background: rgba(255, 254, 250, 0.7);
font-size: 1.08rem;
}
.daily-quiz {
grid-template-columns: repeat(auto-fit, minmax(min(290px, 100%), 1fr));
}
.quiz-question,
.quiz-editor-question {
min-width: 0;
margin: 0;
border: 1px solid var(--button-border);
}
.quiz-question {
display: grid;
gap: 0.85rem;
padding: 1rem;
border-radius: 8px;
background: rgba(255, 254, 250, 0.64);
}
.quiz-question legend,
.quiz-editor-question legend {
padding: 0 0.35rem;
font-weight: 700;
}
.quiz-option {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: 0.65rem;
min-width: 0;
padding: 0.7rem;
border: 1px solid rgba(39, 105, 179, 0.12);
border-radius: 8px;
background: rgba(255, 255, 255, 0.58);
}
.quiz-option.correct {
border-color: rgba(35, 138, 90, 0.42);
background: rgba(223, 247, 229, 0.82);
}
.quiz-option.incorrect {
border-color: rgba(203, 58, 53, 0.36);
background: rgba(255, 236, 232, 0.82);
}
.quiz-option input {
width: auto;
margin: 0.25rem 0 0;
}
.quiz-feedback {
margin: 0;
color: var(--accent-red);
}
.quiz-feedback.correct {
color: var(--accent-green);
}
.admin-education-panel,
.education-admin-basics,
.quiz-editor-question,
.education-admin-list {
display: grid;
gap: 1rem;
}
.education-admin-basics {
grid-template-columns: minmax(180px, 0.35fr) minmax(0, 1fr);
}
.quiz-editor-question {
padding: 1rem;
}
.quiz-editor-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.8rem;
}
.education-admin-list span {
overflow-wrap: anywhere;
}
.button-row { .button-row {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
@@ -1196,6 +1084,35 @@ textarea {
gap: 0.25rem; gap: 0.25rem;
} }
.note-card,
.audit-log-card {
align-items: start;
}
.note-card div,
.audit-log-card div {
display: flex;
gap: 0.75rem;
justify-content: space-between;
align-items: start;
}
.note-card span,
.audit-log-card span,
.audit-log-card small {
color: var(--muted);
}
.note-card p {
margin: 0.35rem 0 0;
white-space: pre-wrap;
}
.note-card .button-row {
justify-content: space-between;
align-items: center;
}
.legend-grid, .legend-grid,
.detail-grid, .detail-grid,
.summary-grid { .summary-grid {
@@ -1207,17 +1124,22 @@ textarea {
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
gap: 1rem; gap: 1rem;
align-items: center; align-items: center;
padding: 1rem; padding: 1rem 6.4rem 1rem 1rem;
border-radius: 24px; border-radius: 24px;
background: linear-gradient(180deg, rgba(255, 252, 247, 0.92), rgba(240, 248, 244, 0.84)); background: linear-gradient(180deg, rgba(255, 252, 247, 0.92), rgba(240, 248, 244, 0.84));
border: 1px solid rgba(39, 105, 179, 0.1); border: 1px solid rgba(39, 105, 179, 0.1);
position: relative; position: relative;
} }
.qr-profile-button { .profile-actions {
position: absolute; position: absolute;
top: 0.85rem; top: 0.85rem;
right: 0.85rem; right: 0.85rem;
display: flex;
gap: 0.45rem;
}
.profile-icon-button {
display: grid; display: grid;
place-items: center; place-items: center;
width: 42px; width: 42px;
@@ -1228,15 +1150,92 @@ textarea {
box-shadow: 0 10px 20px rgba(86, 63, 34, 0.12); box-shadow: 0 10px 20px rgba(86, 63, 34, 0.12);
} }
.qr-profile-button:hover { .profile-icon-button:hover {
transform: translateY(-1px); transform: translateY(-1px);
border-color: rgba(35, 138, 90, 0.34); border-color: rgba(35, 138, 90, 0.34);
} }
.qr-profile-button svg { .profile-icon-button svg {
width: 24px; width: 24px;
height: 24px; height: 24px;
fill: none;
stroke: var(--accent-blue);
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 2;
}
.qr-profile-button svg {
fill: var(--accent-blue); fill: var(--accent-blue);
stroke: none;
}
.bird-detail-tabs {
position: absolute;
top: 5rem;
right: -3rem;
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.bird-detail-tab {
display: grid;
place-items: center;
width: 48px;
height: 44px;
border: 1px solid rgba(39, 105, 179, 0.14);
border-left: 0;
border-radius: 0 14px 14px 0;
background: rgba(255, 254, 250, 0.92);
color: var(--muted);
transition: border-color 0.2s ease, box-shadow 0.2s ease, color 0.2s ease, transform 0.2s ease;
}
.bird-detail-tab svg {
width: 20px;
height: 20px;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 2;
}
.bird-detail-tab .weight-tab-icon {
width: 24px;
height: 24px;
fill: currentColor;
stroke: none;
}
.bird-detail-tab .info-tab-icon,
.bird-detail-tab .note-tab-icon,
.bird-detail-tab .report-tab-icon,
.bird-detail-tab .audit-tab-icon,
.bird-detail-tab .vet-tab-icon {
width: 24px;
height: 24px;
fill: currentColor;
stroke: none;
}
.bird-detail-tab:hover {
border-color: rgba(35, 138, 90, 0.28);
color: var(--ink);
transform: translateY(-1px);
}
.bird-detail-tab.active {
border-color: rgba(35, 138, 90, 0.42);
background: rgba(240, 248, 244, 0.95);
color: var(--accent-green);
box-shadow: 10px 10px 20px rgba(39, 105, 179, 0.1);
}
.bird-detail-tab-panel {
display: grid;
gap: 1rem;
} }
.profile-copy { .profile-copy {
@@ -1708,6 +1707,44 @@ label {
accent-color: var(--accent-green); accent-color: var(--accent-green);
} }
.checkbox-row {
display: flex;
align-items: flex-start;
gap: 0.75rem;
width: min(100%, 420px);
margin: 0.35rem 0 0;
padding: 0.85rem 1rem;
border: 1px solid rgba(53, 129, 98, 0.24);
border-radius: 16px;
background: rgba(255, 255, 255, 0.62);
box-shadow: 0 12px 24px rgba(86, 63, 34, 0.1);
}
.checkbox-row input[type="checkbox"] {
width: 20px;
height: 20px;
flex: 0 0 auto;
margin: 0.1rem 0 0;
padding: 0;
accent-color: var(--accent-green);
}
.checkbox-row span {
display: grid;
gap: 0.15rem;
line-height: 1.3;
}
.checkbox-row strong {
color: var(--ink);
font-size: 0.98rem;
}
.checkbox-row small {
color: var(--muted);
font-size: 0.86rem;
}
.primary-button { .primary-button {
border: 0; border: 0;
border-radius: 18px; border-radius: 18px;
@@ -1966,11 +2003,6 @@ label {
} }
@media (max-width: 980px) { @media (max-width: 980px) {
.education-admin-basics,
.quiz-editor-options {
grid-template-columns: 1fr;
}
.app-shell, .app-shell,
.auth-panel, .auth-panel,
.hero-card, .hero-card,
@@ -2008,6 +2040,29 @@ label {
justify-content: start; justify-content: start;
} }
.bird-detail-panel {
margin-right: 0;
}
.profile-hero {
padding: 1rem;
}
.bird-detail-tabs {
position: static;
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
transform: none;
}
.bird-detail-tab {
width: auto;
min-width: 0;
border-left: 1px solid rgba(39, 105, 179, 0.14);
border-radius: 14px;
}
.side-rail { .side-rail {
position: static; position: static;
grid-template-columns: auto minmax(0, 1fr); grid-template-columns: auto minmax(0, 1fr);