Compare commits
36 Commits
52008f5b43
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 18fd76dc1f | |||
| f627157a14 | |||
| 35bd87b8b5 | |||
| 14bc1c30a0 | |||
| d03672fcdd | |||
| 46e07336ef | |||
| bcaa8c4464 | |||
| 84d850a1ba | |||
| 0dfacc0d17 | |||
| 7ef20ab0fb | |||
| 9ddd85b5c4 | |||
| a988d9662b | |||
| 56068e02a3 | |||
| 1140be8f32 | |||
| 4d3ab0b143 | |||
| 1e98d55cb5 | |||
| 454adc6f5e | |||
| ae8c4326b5 | |||
| 480bbe8fc7 | |||
| bb589e3489 | |||
| c3bec15c63 | |||
| 979a17132d | |||
| cadbdc2a7f | |||
| 8e2f789e9b | |||
| 7e2d06c50b | |||
| c2d518f864 | |||
| c3297b5915 | |||
| 41dda33310 | |||
| c98a5a2863 | |||
| 6c9017c3dc | |||
| 6b11a73579 | |||
| 1f26255ebd | |||
| 14cdfe603d | |||
| d5bb87910e | |||
| 3053e3bef5 | |||
| 6ade13a8be |
@@ -14,6 +14,7 @@ PHOTO_DELIVERY_MODE=proxy
|
|||||||
FRONTEND_URL=http://localhost:3000
|
FRONTEND_URL=http://localhost:3000
|
||||||
BACKEND_URL=http://localhost:5000
|
BACKEND_URL=http://localhost:5000
|
||||||
VITE_API_BASE_URL=http://localhost:5000/api
|
VITE_API_BASE_URL=http://localhost:5000/api
|
||||||
|
MAPBOX_ACCESS_TOKEN=
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
TRUST_PROXY=
|
TRUST_PROXY=
|
||||||
ADMIN_EMAILS=corey@blaishome.online
|
ADMIN_EMAILS=corey@blaishome.online
|
||||||
|
|||||||
@@ -3,14 +3,13 @@ name: Deploy
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
|
||||||
- dev
|
- dev
|
||||||
- develop
|
- develop
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy-dev:
|
deploy-dev:
|
||||||
if: ${{ github.event_name == 'push' && (github.ref_name == 'dev' || github.ref_name == 'develop') }}
|
if: ${{ github.event_name == 'push' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
volumes:
|
volumes:
|
||||||
@@ -49,7 +48,7 @@ jobs:
|
|||||||
docker compose -f docker-compose.dev.yaml up -d --build
|
docker compose -f docker-compose.dev.yaml up -d --build
|
||||||
|
|
||||||
deploy-prod:
|
deploy-prod:
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref_name == 'main') }}
|
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ curl -H "Authorization: Bearer <admin-token>" https://your-host/api/metrics
|
|||||||
- `FRONTEND_URL`
|
- `FRONTEND_URL`
|
||||||
- `BACKEND_URL`
|
- `BACKEND_URL`
|
||||||
- `VITE_API_BASE_URL`
|
- `VITE_API_BASE_URL`
|
||||||
|
- `MAPBOX_ACCESS_TOKEN`
|
||||||
- `REDIS_URL`
|
- `REDIS_URL`
|
||||||
- `IMAGE_STORAGE_PROVIDER`
|
- `IMAGE_STORAGE_PROVIDER`
|
||||||
- `S3_ENDPOINT`
|
- `S3_ENDPOINT`
|
||||||
|
|||||||
Generated
+520
-1
@@ -20,6 +20,7 @@
|
|||||||
"pdfkit": "^0.18.0",
|
"pdfkit": "^0.18.0",
|
||||||
"pg": "8.13.1",
|
"pg": "8.13.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"stripe": "^22.0.2",
|
"stripe": "^22.0.2",
|
||||||
"zod": "3.24.1"
|
"zod": "3.24.1"
|
||||||
},
|
},
|
||||||
@@ -35,6 +36,16 @@
|
|||||||
"typescript": "5.7.2"
|
"typescript": "5.7.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||||
|
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.23.1",
|
"version": "0.23.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz",
|
||||||
@@ -443,6 +454,471 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@img/colour": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-darwin-arm64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-darwin-arm64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-darwin-x64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-darwin-x64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-arm": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-arm": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-arm64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-arm64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-ppc64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-ppc64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-riscv64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-riscv64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-s390x": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-s390x": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-x64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-x64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-wasm32": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
|
||||||
|
"cpu": [
|
||||||
|
"wasm32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/runtime": "^1.7.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-arm64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-ia32": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-x64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@ioredis/commands": {
|
"node_modules/@ioredis/commands": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz",
|
||||||
@@ -1065,7 +1541,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@@ -2383,6 +2858,50 @@
|
|||||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/sharp": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@img/colour": "^1.0.0",
|
||||||
|
"detect-libc": "^2.1.2",
|
||||||
|
"semver": "^7.7.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-darwin-arm64": "0.34.5",
|
||||||
|
"@img/sharp-darwin-x64": "0.34.5",
|
||||||
|
"@img/sharp-libvips-darwin-arm64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-darwin-x64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-arm": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-arm64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-ppc64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-riscv64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-s390x": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-x64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
|
||||||
|
"@img/sharp-linux-arm": "0.34.5",
|
||||||
|
"@img/sharp-linux-arm64": "0.34.5",
|
||||||
|
"@img/sharp-linux-ppc64": "0.34.5",
|
||||||
|
"@img/sharp-linux-riscv64": "0.34.5",
|
||||||
|
"@img/sharp-linux-s390x": "0.34.5",
|
||||||
|
"@img/sharp-linux-x64": "0.34.5",
|
||||||
|
"@img/sharp-linuxmusl-arm64": "0.34.5",
|
||||||
|
"@img/sharp-linuxmusl-x64": "0.34.5",
|
||||||
|
"@img/sharp-wasm32": "0.34.5",
|
||||||
|
"@img/sharp-win32-arm64": "0.34.5",
|
||||||
|
"@img/sharp-win32-ia32": "0.34.5",
|
||||||
|
"@img/sharp-win32-x64": "0.34.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/side-channel": {
|
"node_modules/side-channel": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"pdfkit": "^0.18.0",
|
"pdfkit": "^0.18.0",
|
||||||
"pg": "8.13.1",
|
"pg": "8.13.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"stripe": "^22.0.2",
|
"stripe": "^22.0.2",
|
||||||
"zod": "3.24.1"
|
"zod": "3.24.1"
|
||||||
},
|
},
|
||||||
|
|||||||
+964
-71
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
ALTER TABLE workspaces
|
ALTER TABLE workspaces
|
||||||
DROP CONSTRAINT IF EXISTS workspaces_id_check;
|
DROP CONSTRAINT IF EXISTS workspaces_id_check;
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS education_opt_out BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
ALTER TABLE workspaces
|
ALTER TABLE workspaces
|
||||||
ADD COLUMN IF NOT EXISTS billing_email VARCHAR(255),
|
ADD COLUMN IF NOT EXISTS billing_email VARCHAR(255),
|
||||||
ADD COLUMN IF NOT EXISTS billing_plan VARCHAR(32) NOT NULL DEFAULT 'household_basic',
|
ADD COLUMN IF NOT EXISTS billing_plan VARCHAR(32) NOT NULL DEFAULT 'household_basic',
|
||||||
@@ -139,6 +142,37 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_auth_sessions_created_user
|
CREATE INDEX IF NOT EXISTS idx_auth_sessions_created_user
|
||||||
ON auth_sessions (created_at DESC, user_id);
|
ON auth_sessions (created_at DESC, user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_education (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
publish_date DATE NOT NULL UNIQUE,
|
||||||
|
fact TEXT NOT NULL,
|
||||||
|
quiz_questions JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE daily_education
|
||||||
|
ALTER COLUMN quiz_questions SET DEFAULT '[]'::jsonb;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_daily_education_publish_date
|
||||||
|
ON daily_education (publish_date DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS education_question_bank (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
prompt VARCHAR(500) NOT NULL,
|
||||||
|
options JSONB NOT NULL,
|
||||||
|
correct_answer_index INTEGER NOT NULL,
|
||||||
|
explanation VARCHAR(800),
|
||||||
|
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CHECK (correct_answer_index >= 0 AND correct_answer_index <= 3)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_education_question_bank_created
|
||||||
|
ON education_question_bank (created_at DESC);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS integration_tokens (
|
CREATE TABLE IF NOT EXISTS integration_tokens (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
@@ -215,6 +249,8 @@ 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),
|
||||||
|
location_label VARCHAR(160),
|
||||||
|
location_details JSONB,
|
||||||
vet_clinic_name VARCHAR(160),
|
vet_clinic_name VARCHAR(160),
|
||||||
vet_clinic_address VARCHAR(500),
|
vet_clinic_address VARCHAR(500),
|
||||||
vet_account_number VARCHAR(120),
|
vet_account_number VARCHAR(120),
|
||||||
@@ -243,6 +279,8 @@ 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 location_label VARCHAR(160),
|
||||||
|
ADD COLUMN IF NOT EXISTS location_details JSONB,
|
||||||
ADD COLUMN IF NOT EXISTS vet_clinic_name VARCHAR(160),
|
ADD COLUMN IF NOT EXISTS vet_clinic_name VARCHAR(160),
|
||||||
ADD COLUMN IF NOT EXISTS vet_clinic_address VARCHAR(500),
|
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_account_number VARCHAR(120),
|
||||||
@@ -368,6 +406,32 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
WHERE completed_at IS NULL
|
WHERE completed_at IS NULL
|
||||||
AND revoked_at IS NULL;
|
AND revoked_at IS NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bird_timeline_events (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
||||||
|
event_type VARCHAR(40) NOT NULL,
|
||||||
|
from_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL,
|
||||||
|
to_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL,
|
||||||
|
from_workspace_name VARCHAR(160),
|
||||||
|
to_workspace_name VARCHAR(160),
|
||||||
|
from_owner_email VARCHAR(255),
|
||||||
|
to_owner_email VARCHAR(255),
|
||||||
|
location_label VARCHAR(160),
|
||||||
|
location_details JSONB,
|
||||||
|
note TEXT,
|
||||||
|
event_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||||
|
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE bird_timeline_events
|
||||||
|
ADD COLUMN IF NOT EXISTS note TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS location_details JSONB,
|
||||||
|
ADD COLUMN IF NOT EXISTS event_date DATE NOT NULL DEFAULT CURRENT_DATE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bird_timeline_events_bird_created
|
||||||
|
ON bird_timeline_events (bird_id, event_date DESC, created_at DESC);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS flock_notes (
|
CREATE TABLE IF NOT EXISTS flock_notes (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
@@ -437,6 +501,7 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
start_date DATE NOT NULL,
|
start_date DATE NOT NULL,
|
||||||
end_date DATE,
|
end_date DATE,
|
||||||
notes VARCHAR(1000),
|
notes VARCHAR(1000),
|
||||||
|
reminders_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
CHECK (end_date IS NULL OR end_date >= start_date)
|
CHECK (end_date IS NULL OR end_date >= start_date)
|
||||||
);
|
);
|
||||||
@@ -444,6 +509,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
ALTER TABLE medications
|
ALTER TABLE medications
|
||||||
ADD COLUMN IF NOT EXISTS dose_schedule JSONB NOT NULL DEFAULT '[{"key":"dose-1","label":"Dose","time":""}]'::jsonb;
|
ADD COLUMN IF NOT EXISTS dose_schedule JSONB NOT NULL DEFAULT '[{"key":"dose-1","label":"Dose","time":""}]'::jsonb;
|
||||||
|
|
||||||
|
ALTER TABLE medications
|
||||||
|
ADD COLUMN IF NOT EXISTS reminders_enabled BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS bird_milestone_reminder_deliveries (
|
CREATE TABLE IF NOT EXISTS bird_milestone_reminder_deliveries (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
||||||
@@ -477,6 +545,17 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
ALTER TABLE medication_administrations
|
ALTER TABLE medication_administrations
|
||||||
ADD COLUMN IF NOT EXISTS administration_slot VARCHAR(80) NOT NULL DEFAULT 'dose-1';
|
ADD COLUMN IF NOT EXISTS administration_slot VARCHAR(80) NOT NULL DEFAULT 'dose-1';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS medication_reminder_deliveries (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
medication_id UUID NOT NULL REFERENCES medications(id) ON DELETE CASCADE,
|
||||||
|
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
||||||
|
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
|
scheduled_on DATE NOT NULL,
|
||||||
|
administration_slot VARCHAR(80) NOT NULL,
|
||||||
|
delivered_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (medication_id, scheduled_on, administration_slot)
|
||||||
|
);
|
||||||
|
|
||||||
ALTER TABLE medication_administrations
|
ALTER TABLE medication_administrations
|
||||||
DROP CONSTRAINT IF EXISTS medication_administrations_medication_id_administered_on_key;
|
DROP CONSTRAINT IF EXISTS medication_administrations_medication_id_administered_on_key;
|
||||||
|
|
||||||
@@ -495,6 +574,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_bird_milestone_reminder_deliveries_workspace
|
CREATE INDEX IF NOT EXISTS idx_bird_milestone_reminder_deliveries_workspace
|
||||||
ON bird_milestone_reminder_deliveries (workspace_id, delivered_on DESC);
|
ON bird_milestone_reminder_deliveries (workspace_id, delivered_on DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_medication_reminder_deliveries_workspace
|
||||||
|
ON medication_reminder_deliveries (workspace_id, scheduled_on DESC);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_medication_administrations_bird_administered_on
|
CREATE INDEX IF NOT EXISTS idx_medication_administrations_bird_administered_on
|
||||||
ON medication_administrations (bird_id, administered_on DESC);
|
ON medication_administrations (bird_id, administered_on DESC);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Queue, QueueEvents, type Job } from 'bullmq';
|
||||||
|
|
||||||
|
import { redisConnection } from './redisConnection.js';
|
||||||
|
|
||||||
|
export type AdoptionReportJobData = {
|
||||||
|
birdId: string;
|
||||||
|
workspaceId: number;
|
||||||
|
transferCode: string;
|
||||||
|
printFriendly: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdoptionReportJobResult = {
|
||||||
|
pdfBase64: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const adoptionReportQueueName = 'adoption-reports';
|
||||||
|
|
||||||
|
export const adoptionReportQueue = new Queue<AdoptionReportJobData, AdoptionReportJobResult>(adoptionReportQueueName, {
|
||||||
|
connection: redisConnection,
|
||||||
|
defaultJobOptions: {
|
||||||
|
attempts: 2,
|
||||||
|
backoff: {
|
||||||
|
type: 'exponential',
|
||||||
|
delay: 10_000,
|
||||||
|
},
|
||||||
|
removeOnComplete: 50,
|
||||||
|
removeOnFail: 500,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const adoptionReportQueueEvents = new QueueEvents(adoptionReportQueueName, {
|
||||||
|
connection: redisConnection,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const enqueueAdoptionReportJob = (
|
||||||
|
data: AdoptionReportJobData,
|
||||||
|
): Promise<Job<AdoptionReportJobData, AdoptionReportJobResult>> => adoptionReportQueue.add('render-adoption-report', data);
|
||||||
|
|
||||||
|
export const closeAdoptionReportQueue = async () => {
|
||||||
|
await adoptionReportQueue.close();
|
||||||
|
await adoptionReportQueueEvents.close();
|
||||||
|
};
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Queue, type Job } from 'bullmq';
|
||||||
|
|
||||||
|
import { redisConnection } from './redisConnection.js';
|
||||||
|
|
||||||
|
export type MedicationReminderJobData = {
|
||||||
|
runDate: string;
|
||||||
|
currentTime: string;
|
||||||
|
requestedBy: 'scheduler';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MedicationReminderJobResult = {
|
||||||
|
runDate: string;
|
||||||
|
currentTime: string;
|
||||||
|
checked: number;
|
||||||
|
sent: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const medicationReminderQueueName = 'medication-reminders';
|
||||||
|
|
||||||
|
export const medicationReminderQueue = new Queue<MedicationReminderJobData, MedicationReminderJobResult>(medicationReminderQueueName, {
|
||||||
|
connection: redisConnection,
|
||||||
|
defaultJobOptions: {
|
||||||
|
attempts: 3,
|
||||||
|
backoff: {
|
||||||
|
type: 'exponential',
|
||||||
|
delay: 60_000,
|
||||||
|
},
|
||||||
|
removeOnComplete: 100,
|
||||||
|
removeOnFail: 1_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const enqueueMedicationReminderJob = (
|
||||||
|
runDate: string,
|
||||||
|
currentTime: string,
|
||||||
|
): Promise<Job<MedicationReminderJobData, MedicationReminderJobResult>> =>
|
||||||
|
medicationReminderQueue.add(
|
||||||
|
'run-medication-reminders',
|
||||||
|
{
|
||||||
|
runDate,
|
||||||
|
currentTime,
|
||||||
|
requestedBy: 'scheduler',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jobId: `medication-reminders-${runDate}-${currentTime.slice(0, 2)}`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const closeMedicationReminderQueue = async () => {
|
||||||
|
await medicationReminderQueue.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMedicationReminderQueueCounts = () => medicationReminderQueue.getJobCounts('waiting', 'active', 'delayed', 'completed', 'failed');
|
||||||
@@ -61,11 +61,17 @@ const formatWeight = (value: string | number | null) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const genderLabel = (value: string) => {
|
const genderLabel = (value: string) => {
|
||||||
|
if (value === 'female_dna') {
|
||||||
|
return 'Female (DNA confirmed)';
|
||||||
|
}
|
||||||
|
if (value === 'male_dna') {
|
||||||
|
return 'Male (DNA confirmed)';
|
||||||
|
}
|
||||||
if (value === 'female') {
|
if (value === 'female') {
|
||||||
return 'Female';
|
return 'Female (assumed)';
|
||||||
}
|
}
|
||||||
if (value === 'male') {
|
if (value === 'male') {
|
||||||
return 'Male';
|
return 'Male (assumed)';
|
||||||
}
|
}
|
||||||
return 'Unknown';
|
return 'Unknown';
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import path from 'path';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
import { listFlockNotes } from '../repositories/auditRepository.js';
|
||||||
|
import { getBirdById, listVetVisitsForBird, listWeightsForBird } from '../repositories/birdRepository.js';
|
||||||
|
import { getS3ImageStorageConfig } from '../storage/imageStorageConfig.js';
|
||||||
|
import { getSignedS3ObjectUrl } from '../storage/s3Client.js';
|
||||||
|
import type { BirdRow } from '../types.js';
|
||||||
|
import { renderAdoptionReportPdf } from './adoptionReport.js';
|
||||||
|
|
||||||
|
const adoptionReportWeightHistoryDays = 14;
|
||||||
|
|
||||||
|
const parseDataImage = (value: string | null) => {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = value.match(/^data:image\/(?:png|jpeg|jpg|webp|gif);base64,(.+)$/);
|
||||||
|
return match ? Buffer.from(match[1], 'base64') : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeReportPhotoBuffer = async (imageBuffer: Buffer | null) => {
|
||||||
|
if (!imageBuffer) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await sharp(imageBuffer).rotate().png().toBuffer();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Unable to normalize bird photo for adoption report:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadBirdReportPhotoBuffer = async (bird: BirdRow) => {
|
||||||
|
if (!bird.photo_object_key) {
|
||||||
|
return normalizeReportPhotoBuffer(parseDataImage(bird.photo_data_url));
|
||||||
|
}
|
||||||
|
|
||||||
|
const s3Config = getS3ImageStorageConfig();
|
||||||
|
|
||||||
|
if (!s3Config) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const signedUrl = getSignedS3ObjectUrl({
|
||||||
|
config: s3Config,
|
||||||
|
objectKey: bird.photo_object_key,
|
||||||
|
expiresInSeconds: 5 * 60,
|
||||||
|
});
|
||||||
|
const imageResponse = await fetch(signedUrl);
|
||||||
|
|
||||||
|
if (!imageResponse.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeReportPhotoBuffer(Buffer.from(await imageResponse.arrayBuffer()));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const renderAdoptionReportForBird = async ({
|
||||||
|
birdId,
|
||||||
|
workspaceId,
|
||||||
|
transferCode,
|
||||||
|
printFriendly,
|
||||||
|
}: {
|
||||||
|
birdId: string;
|
||||||
|
workspaceId: number;
|
||||||
|
transferCode: string;
|
||||||
|
printFriendly: boolean;
|
||||||
|
}) => {
|
||||||
|
const bird = await getBirdById(birdId, workspaceId);
|
||||||
|
|
||||||
|
if (!bird) {
|
||||||
|
throw new Error('Bird not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [weights, vetVisits, notes, birdPhotoBuffer] = await Promise.all([
|
||||||
|
listWeightsForBird(bird.id, workspaceId, adoptionReportWeightHistoryDays),
|
||||||
|
listVetVisitsForBird(bird.id, workspaceId),
|
||||||
|
listFlockNotes(workspaceId),
|
||||||
|
loadBirdReportPhotoBuffer(bird),
|
||||||
|
]);
|
||||||
|
const birdNotes = notes.filter((note) => note.bird_id === bird.id);
|
||||||
|
|
||||||
|
return renderAdoptionReportPdf({
|
||||||
|
bird,
|
||||||
|
weights,
|
||||||
|
vetVisits,
|
||||||
|
notes: birdNotes,
|
||||||
|
transferCode,
|
||||||
|
birdPhotoBuffer,
|
||||||
|
printFriendly,
|
||||||
|
assets: {
|
||||||
|
logoPath: path.join(process.cwd(), 'assets', 'flockpal-logo.png'),
|
||||||
|
wordmarkPath: path.join(process.cwd(), 'assets', 'flockpal-text.png'),
|
||||||
|
defaultBirdPhotoPath: path.join(process.cwd(), 'assets', 'yoda-default.png'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -188,6 +188,46 @@ test('completePendingBirdTransfersForOwner moves pending birds and marks complet
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ rowCount: 1, rows: [] },
|
{ rowCount: 1, rows: [] },
|
||||||
|
{
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
workspace_id: 10,
|
||||||
|
workspace_name: 'Original Flock',
|
||||||
|
owner_email: 'sender@example.com',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
workspace_id: 22,
|
||||||
|
workspace_name: 'Receiving Flock',
|
||||||
|
owner_email: 'receiver@example.com',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: 'timeline-1',
|
||||||
|
bird_id: 'bird-1',
|
||||||
|
event_type: 'transferred',
|
||||||
|
from_workspace_id: 10,
|
||||||
|
to_workspace_id: 22,
|
||||||
|
from_workspace_name: 'Original Flock',
|
||||||
|
to_workspace_name: 'Receiving Flock',
|
||||||
|
from_owner_email: 'sender@example.com',
|
||||||
|
to_owner_email: 'receiver@example.com',
|
||||||
|
location_label: 'Receiving Flock',
|
||||||
|
location_details: null,
|
||||||
|
created_by_user_id: 'user-1',
|
||||||
|
created_at: '2026-04-15T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await completePendingBirdTransfersForOwner('receiver@example.com', 22);
|
const result = await completePendingBirdTransfersForOwner('receiver@example.com', 22);
|
||||||
@@ -197,4 +237,19 @@ test('completePendingBirdTransfersForOwner moves pending birds and marks complet
|
|||||||
assert.deepEqual(calls[1].params, ['bird-1', 10, 22]);
|
assert.deepEqual(calls[1].params, ['bird-1', 10, 22]);
|
||||||
assert.deepEqual(calls[2].params, ['transfer-1', 22]);
|
assert.deepEqual(calls[2].params, ['transfer-1', 22]);
|
||||||
assert.match(calls[2].text, /completed_at = CURRENT_TIMESTAMP/);
|
assert.match(calls[2].text, /completed_at = CURRENT_TIMESTAMP/);
|
||||||
|
assert.deepEqual(calls[5].params, [
|
||||||
|
'bird-1',
|
||||||
|
'transferred',
|
||||||
|
10,
|
||||||
|
22,
|
||||||
|
'Original Flock',
|
||||||
|
'Receiving Flock',
|
||||||
|
'sender@example.com',
|
||||||
|
'receiver@example.com',
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
'user-1',
|
||||||
|
null,
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,10 +5,14 @@ import type {
|
|||||||
BirdMilestoneReminderDeliveryRow,
|
BirdMilestoneReminderDeliveryRow,
|
||||||
BirdMilestoneReminderType,
|
BirdMilestoneReminderType,
|
||||||
BirdRow,
|
BirdRow,
|
||||||
|
BirdTimelineEventRow,
|
||||||
|
BirdTimelineEventType,
|
||||||
BirdTransferCodeRow,
|
BirdTransferCodeRow,
|
||||||
LostBirdMatchRow,
|
LostBirdMatchRow,
|
||||||
MedicationAdministrationRow,
|
MedicationAdministrationRow,
|
||||||
MedicationDoseScheduleItem,
|
MedicationDoseScheduleItem,
|
||||||
|
MedicationReminderCandidateRow,
|
||||||
|
MedicationReminderDeliveryRow,
|
||||||
MedicationRow,
|
MedicationRow,
|
||||||
PendingBirdTransferRow,
|
PendingBirdTransferRow,
|
||||||
VetVisitRow,
|
VetVisitRow,
|
||||||
@@ -24,6 +28,8 @@ const birdSelectFields = `
|
|||||||
birds.motivators,
|
birds.motivators,
|
||||||
birds.demotivators,
|
birds.demotivators,
|
||||||
birds.favorite_snack,
|
birds.favorite_snack,
|
||||||
|
birds.location_label,
|
||||||
|
birds.location_details,
|
||||||
birds.vet_clinic_name,
|
birds.vet_clinic_name,
|
||||||
birds.vet_clinic_address,
|
birds.vet_clinic_address,
|
||||||
birds.vet_account_number,
|
birds.vet_account_number,
|
||||||
@@ -49,6 +55,34 @@ const birdSelectFields = `
|
|||||||
latest.recorded_on::text AS latest_recorded_on
|
latest.recorded_on::text AS latest_recorded_on
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
type WorkspaceTimelineSnapshot = {
|
||||||
|
workspace_id: number;
|
||||||
|
workspace_name: string;
|
||||||
|
owner_email: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getWorkspaceTimelineSnapshot = async (workspaceId: number) => {
|
||||||
|
const result = await db.query<WorkspaceTimelineSnapshot>(
|
||||||
|
`SELECT
|
||||||
|
workspaces.id AS workspace_id,
|
||||||
|
workspaces.name AS workspace_name,
|
||||||
|
COALESCE(workspaces.billing_email, owner_member.invite_email, owner_member.email) AS owner_email
|
||||||
|
FROM workspaces
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT invite_email, email
|
||||||
|
FROM workspace_members
|
||||||
|
WHERE workspace_members.workspace_id = workspaces.id
|
||||||
|
AND workspace_members.role = 'owner'
|
||||||
|
ORDER BY accepted_at DESC NULLS LAST, created_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
) owner_member ON TRUE
|
||||||
|
WHERE workspaces.id = $1`,
|
||||||
|
[workspaceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
export const getBirdById = async (birdId: string, workspaceId: number) => {
|
export const getBirdById = async (birdId: string, workspaceId: number) => {
|
||||||
const result = await db.query<BirdRow>(
|
const result = await db.query<BirdRow>(
|
||||||
`SELECT
|
`SELECT
|
||||||
@@ -132,6 +166,102 @@ export const listMemorializedBirds = async (workspaceId: number) => {
|
|||||||
return result.rows;
|
return result.rows;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const createBirdTimelineEvent = async ({
|
||||||
|
birdId,
|
||||||
|
eventType,
|
||||||
|
fromWorkspaceId,
|
||||||
|
toWorkspaceId,
|
||||||
|
locationLabel,
|
||||||
|
locationDetails,
|
||||||
|
note,
|
||||||
|
eventDate,
|
||||||
|
createdByUserId,
|
||||||
|
}: {
|
||||||
|
birdId: string;
|
||||||
|
eventType: BirdTimelineEventType;
|
||||||
|
fromWorkspaceId?: number | null;
|
||||||
|
toWorkspaceId?: number | null;
|
||||||
|
locationLabel?: string | null;
|
||||||
|
locationDetails?: Record<string, unknown> | null;
|
||||||
|
note?: string | null;
|
||||||
|
eventDate?: string | null;
|
||||||
|
createdByUserId?: string | null;
|
||||||
|
}) => {
|
||||||
|
const [fromWorkspace, toWorkspace] = await Promise.all([
|
||||||
|
fromWorkspaceId ? getWorkspaceTimelineSnapshot(fromWorkspaceId) : Promise.resolve(null),
|
||||||
|
toWorkspaceId ? getWorkspaceTimelineSnapshot(toWorkspaceId) : Promise.resolve(null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await db.query<BirdTimelineEventRow>(
|
||||||
|
`INSERT INTO bird_timeline_events (
|
||||||
|
bird_id,
|
||||||
|
event_type,
|
||||||
|
from_workspace_id,
|
||||||
|
to_workspace_id,
|
||||||
|
from_workspace_name,
|
||||||
|
to_workspace_name,
|
||||||
|
from_owner_email,
|
||||||
|
to_owner_email,
|
||||||
|
location_label,
|
||||||
|
note,
|
||||||
|
event_date,
|
||||||
|
created_by_user_id,
|
||||||
|
location_details
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
$1,
|
||||||
|
$2,
|
||||||
|
$3,
|
||||||
|
$4,
|
||||||
|
$5::varchar(160),
|
||||||
|
$6::varchar(160),
|
||||||
|
$7::varchar(320),
|
||||||
|
$8::varchar(320),
|
||||||
|
COALESCE($9::varchar(160), $6::varchar(160), $5::varchar(160)),
|
||||||
|
$10,
|
||||||
|
COALESCE($11::date, CURRENT_DATE),
|
||||||
|
$12,
|
||||||
|
$13
|
||||||
|
)
|
||||||
|
RETURNING id, bird_id, event_type, from_workspace_id, to_workspace_id, from_workspace_name, to_workspace_name, from_owner_email, to_owner_email, location_label, location_details, note, event_date::text, created_by_user_id, created_at`,
|
||||||
|
[
|
||||||
|
birdId,
|
||||||
|
eventType,
|
||||||
|
fromWorkspaceId ?? null,
|
||||||
|
toWorkspaceId ?? null,
|
||||||
|
fromWorkspace?.workspace_name ?? null,
|
||||||
|
toWorkspace?.workspace_name ?? null,
|
||||||
|
fromWorkspace?.owner_email ?? null,
|
||||||
|
toWorkspace?.owner_email ?? null,
|
||||||
|
locationLabel ?? null,
|
||||||
|
note ?? null,
|
||||||
|
eventDate ?? null,
|
||||||
|
createdByUserId ?? null,
|
||||||
|
locationDetails ?? null,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listBirdTimelineEvents = async (birdId: string, workspaceId: number) => {
|
||||||
|
const result = await db.query<BirdTimelineEventRow>(
|
||||||
|
`SELECT id, bird_id, event_type, from_workspace_id, to_workspace_id, from_workspace_name, to_workspace_name, from_owner_email, to_owner_email, location_label, location_details, note, event_date::text, created_by_user_id, created_at
|
||||||
|
FROM bird_timeline_events
|
||||||
|
WHERE bird_id = $1
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM birds
|
||||||
|
WHERE birds.id = bird_timeline_events.bird_id
|
||||||
|
AND birds.workspace_id = $2
|
||||||
|
)
|
||||||
|
ORDER BY event_date DESC, created_at DESC`,
|
||||||
|
[birdId, workspaceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
export const findBirdsByBandId = async (tagId: string) => {
|
export const findBirdsByBandId = async (tagId: string) => {
|
||||||
const result = await db.query<LostBirdMatchRow>(
|
const result = await db.query<LostBirdMatchRow>(
|
||||||
`SELECT
|
`SELECT
|
||||||
@@ -283,6 +413,79 @@ export const createBirdMilestoneReminderDelivery = async ({
|
|||||||
return result.rows[0] ?? null;
|
return result.rows[0] ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const listDueMedicationReminders = async (runDate: string, currentTime: string) => {
|
||||||
|
const result = await db.query<MedicationReminderCandidateRow>(
|
||||||
|
`SELECT
|
||||||
|
${birdSelectFields},
|
||||||
|
workspaces.name AS workspace_name,
|
||||||
|
medications.id AS medication_id,
|
||||||
|
medications.name AS medication_name,
|
||||||
|
medications.dosage,
|
||||||
|
medications.frequency,
|
||||||
|
medications.dose_schedule,
|
||||||
|
medications.route,
|
||||||
|
medications.start_date::text AS medication_start_date,
|
||||||
|
medications.end_date::text AS medication_end_date,
|
||||||
|
medications.notes AS medication_notes,
|
||||||
|
$1::date::text AS scheduled_on,
|
||||||
|
dose.key AS administration_slot,
|
||||||
|
dose.label AS administration_label,
|
||||||
|
dose.time AS administration_time
|
||||||
|
FROM medications
|
||||||
|
INNER JOIN birds ON birds.id = medications.bird_id
|
||||||
|
INNER JOIN workspaces ON workspaces.id = birds.workspace_id
|
||||||
|
CROSS JOIN LATERAL jsonb_to_recordset(medications.dose_schedule) AS dose(key text, label text, time text)
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT weight_grams, recorded_on
|
||||||
|
FROM weight_records
|
||||||
|
WHERE weight_records.bird_id = birds.id
|
||||||
|
ORDER BY recorded_on DESC
|
||||||
|
LIMIT 1
|
||||||
|
) latest ON TRUE
|
||||||
|
WHERE medications.reminders_enabled = TRUE
|
||||||
|
AND birds.memorialized_at IS NULL
|
||||||
|
AND medications.start_date <= $1::date
|
||||||
|
AND (medications.end_date IS NULL OR medications.end_date >= $1::date)
|
||||||
|
AND COALESCE(NULLIF(BTRIM(dose.time), ''), '') <> ''
|
||||||
|
AND dose.time <= $2
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM medication_reminder_deliveries deliveries
|
||||||
|
WHERE deliveries.medication_id = medications.id
|
||||||
|
AND deliveries.scheduled_on = $1::date
|
||||||
|
AND deliveries.administration_slot = dose.key
|
||||||
|
)
|
||||||
|
ORDER BY workspaces.name ASC, birds.name ASC, dose.time ASC, medications.name ASC`,
|
||||||
|
[runDate, currentTime],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createMedicationReminderDelivery = async ({
|
||||||
|
medicationId,
|
||||||
|
birdId,
|
||||||
|
workspaceId,
|
||||||
|
scheduledOn,
|
||||||
|
administrationSlot,
|
||||||
|
}: {
|
||||||
|
medicationId: string;
|
||||||
|
birdId: string;
|
||||||
|
workspaceId: number;
|
||||||
|
scheduledOn: string;
|
||||||
|
administrationSlot: string;
|
||||||
|
}) => {
|
||||||
|
const result = await db.query<MedicationReminderDeliveryRow>(
|
||||||
|
`INSERT INTO medication_reminder_deliveries (medication_id, bird_id, workspace_id, scheduled_on, administration_slot)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (medication_id, scheduled_on, administration_slot) DO NOTHING
|
||||||
|
RETURNING id, medication_id, bird_id, workspace_id, scheduled_on::text, administration_slot, delivered_at`,
|
||||||
|
[medicationId, birdId, workspaceId, scheduledOn, administrationSlot],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
export const createBird = async ({
|
export const createBird = async ({
|
||||||
birdId,
|
birdId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -292,6 +495,8 @@ export const createBird = async ({
|
|||||||
motivators,
|
motivators,
|
||||||
demotivators,
|
demotivators,
|
||||||
favoriteSnack,
|
favoriteSnack,
|
||||||
|
locationLabel = null,
|
||||||
|
locationDetails = null,
|
||||||
vetClinicName = null,
|
vetClinicName = null,
|
||||||
vetClinicAddress = null,
|
vetClinicAddress = null,
|
||||||
vetAccountNumber = null,
|
vetAccountNumber = null,
|
||||||
@@ -317,6 +522,8 @@ export const createBird = async ({
|
|||||||
motivators: string | null;
|
motivators: string | null;
|
||||||
demotivators: string | null;
|
demotivators: string | null;
|
||||||
favoriteSnack: string | null;
|
favoriteSnack: string | null;
|
||||||
|
locationLabel?: string | null;
|
||||||
|
locationDetails?: Record<string, unknown> | null;
|
||||||
vetClinicName?: string | null;
|
vetClinicName?: string | null;
|
||||||
vetClinicAddress?: string | null;
|
vetClinicAddress?: string | null;
|
||||||
vetAccountNumber?: string | null;
|
vetAccountNumber?: string | null;
|
||||||
@@ -335,9 +542,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, 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)
|
`INSERT INTO birds (id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, location_details, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth, gotcha_day, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled)
|
||||||
VALUES (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)
|
VALUES (COALESCE($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26)
|
||||||
RETURNING id, workspace_id, name, tag_id, species, 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`,
|
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, location_details, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`,
|
||||||
[
|
[
|
||||||
birdId ?? null,
|
birdId ?? null,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -347,6 +554,8 @@ export const createBird = async ({
|
|||||||
motivators,
|
motivators,
|
||||||
demotivators,
|
demotivators,
|
||||||
favoriteSnack,
|
favoriteSnack,
|
||||||
|
locationLabel,
|
||||||
|
locationDetails,
|
||||||
vetClinicName,
|
vetClinicName,
|
||||||
vetClinicAddress,
|
vetClinicAddress,
|
||||||
vetAccountNumber,
|
vetAccountNumber,
|
||||||
@@ -378,6 +587,8 @@ export const updateBird = async ({
|
|||||||
motivators,
|
motivators,
|
||||||
demotivators,
|
demotivators,
|
||||||
favoriteSnack,
|
favoriteSnack,
|
||||||
|
locationLabel,
|
||||||
|
locationDetails,
|
||||||
vetClinicName,
|
vetClinicName,
|
||||||
vetClinicAddress,
|
vetClinicAddress,
|
||||||
vetAccountNumber,
|
vetAccountNumber,
|
||||||
@@ -403,6 +614,8 @@ export const updateBird = async ({
|
|||||||
motivators: string | null;
|
motivators: string | null;
|
||||||
demotivators: string | null;
|
demotivators: string | null;
|
||||||
favoriteSnack: string | null;
|
favoriteSnack: string | null;
|
||||||
|
locationLabel: string | null;
|
||||||
|
locationDetails?: Record<string, unknown> | null;
|
||||||
vetClinicName: string | null;
|
vetClinicName: string | null;
|
||||||
vetClinicAddress: string | null;
|
vetClinicAddress: string | null;
|
||||||
vetAccountNumber: string | null;
|
vetAccountNumber: string | null;
|
||||||
@@ -428,26 +641,28 @@ export const updateBird = async ({
|
|||||||
motivators = $5,
|
motivators = $5,
|
||||||
demotivators = $6,
|
demotivators = $6,
|
||||||
favorite_snack = $7,
|
favorite_snack = $7,
|
||||||
vet_clinic_name = $8,
|
location_label = $8,
|
||||||
vet_clinic_address = $9,
|
vet_clinic_name = $9,
|
||||||
vet_account_number = $10,
|
vet_clinic_address = $10,
|
||||||
vet_doctor_name = $11,
|
vet_account_number = $11,
|
||||||
gender = $12,
|
vet_doctor_name = $12,
|
||||||
date_of_birth = $13,
|
gender = $13,
|
||||||
gotcha_day = $14,
|
date_of_birth = $14,
|
||||||
chart_color = $15,
|
gotcha_day = $15,
|
||||||
photo_data_url = $16,
|
chart_color = $16,
|
||||||
photo_object_key = $17,
|
photo_data_url = $17,
|
||||||
photo_content_type = $18,
|
photo_object_key = $18,
|
||||||
photo_updated_at = $19,
|
photo_content_type = $19,
|
||||||
notify_on_dob = $20,
|
photo_updated_at = $20,
|
||||||
notify_on_gotcha_day = $21,
|
notify_on_dob = $21,
|
||||||
public_profile_code = $22,
|
notify_on_gotcha_day = $22,
|
||||||
public_profile_enabled = $23
|
public_profile_code = $23,
|
||||||
|
public_profile_enabled = $24,
|
||||||
|
location_details = $25
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND workspace_id = $24
|
AND workspace_id = $26
|
||||||
AND memorialized_at IS NULL
|
AND memorialized_at IS NULL
|
||||||
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,
|
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, location_details, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
||||||
(
|
(
|
||||||
SELECT weight_grams::text
|
SELECT weight_grams::text
|
||||||
FROM weight_records
|
FROM weight_records
|
||||||
@@ -470,6 +685,7 @@ export const updateBird = async ({
|
|||||||
motivators,
|
motivators,
|
||||||
demotivators,
|
demotivators,
|
||||||
favoriteSnack,
|
favoriteSnack,
|
||||||
|
locationLabel,
|
||||||
vetClinicName,
|
vetClinicName,
|
||||||
vetClinicAddress,
|
vetClinicAddress,
|
||||||
vetAccountNumber,
|
vetAccountNumber,
|
||||||
@@ -486,6 +702,7 @@ export const updateBird = async ({
|
|||||||
notifyOnGotchaDay,
|
notifyOnGotchaDay,
|
||||||
publicProfileCode,
|
publicProfileCode,
|
||||||
publicProfileEnabled,
|
publicProfileEnabled,
|
||||||
|
locationDetails ?? null,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -515,7 +732,7 @@ export const memorializeBird = async ({
|
|||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND workspace_id = $2
|
AND workspace_id = $2
|
||||||
AND memorialized_at IS NULL
|
AND memorialized_at IS NULL
|
||||||
RETURNING id, workspace_id, name, tag_id, species, 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,
|
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, location_details, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
||||||
(
|
(
|
||||||
SELECT weight_grams::text
|
SELECT weight_grams::text
|
||||||
FROM weight_records
|
FROM weight_records
|
||||||
@@ -551,7 +768,7 @@ export const updateMemorialReminderPreference = async ({
|
|||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND workspace_id = $2
|
AND workspace_id = $2
|
||||||
AND memorialized_at IS NOT NULL
|
AND memorialized_at IS NOT NULL
|
||||||
RETURNING id, workspace_id, name, tag_id, species, 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,
|
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, location_details, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
||||||
(
|
(
|
||||||
SELECT weight_grams::text
|
SELECT weight_grams::text
|
||||||
FROM weight_records
|
FROM weight_records
|
||||||
@@ -591,7 +808,7 @@ export const transferBirdToWorkspace = async (birdId: string, sourceWorkspaceId:
|
|||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND workspace_id = $2
|
AND workspace_id = $2
|
||||||
AND memorialized_at IS NULL
|
AND memorialized_at IS NULL
|
||||||
RETURNING id, workspace_id, name, tag_id, species, 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,
|
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, location_details, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
||||||
(
|
(
|
||||||
SELECT weight_grams::text
|
SELECT weight_grams::text
|
||||||
FROM weight_records
|
FROM weight_records
|
||||||
@@ -687,6 +904,17 @@ export const completePendingBirdTransfersForOwner = async (ownerEmail: string, t
|
|||||||
}
|
}
|
||||||
|
|
||||||
await markPendingBirdTransferCompleted(transfer.id, targetWorkspaceId);
|
await markPendingBirdTransferCompleted(transfer.id, targetWorkspaceId);
|
||||||
|
try {
|
||||||
|
await createBirdTimelineEvent({
|
||||||
|
birdId: bird.id,
|
||||||
|
eventType: 'transferred',
|
||||||
|
fromWorkspaceId: transfer.source_workspace_id,
|
||||||
|
toWorkspaceId: targetWorkspaceId,
|
||||||
|
createdByUserId: transfer.requested_by_user_id,
|
||||||
|
});
|
||||||
|
} catch (timelineError) {
|
||||||
|
console.error('Unable to write bird timeline event', timelineError);
|
||||||
|
}
|
||||||
completed += 1;
|
completed += 1;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failed += 1;
|
failed += 1;
|
||||||
@@ -820,6 +1048,34 @@ export const createWeightForBird = async (birdId: string, weightGrams: number, r
|
|||||||
return result.rows[0] ?? null;
|
return result.rows[0] ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const updateWeightForBird = async (
|
||||||
|
weightId: string,
|
||||||
|
birdId: string,
|
||||||
|
weightGrams: number,
|
||||||
|
recordedOn: string,
|
||||||
|
notes: string | null,
|
||||||
|
) => {
|
||||||
|
const result = await db.query<WeightRow>(
|
||||||
|
`UPDATE weight_records
|
||||||
|
SET weight_grams = $3,
|
||||||
|
recorded_on = $4,
|
||||||
|
notes = $5
|
||||||
|
WHERE id = $1
|
||||||
|
AND bird_id = $2
|
||||||
|
AND id IN (
|
||||||
|
SELECT recent.id
|
||||||
|
FROM weight_records recent
|
||||||
|
WHERE recent.bird_id = $2
|
||||||
|
ORDER BY recent.recorded_on DESC, recent.created_at DESC
|
||||||
|
LIMIT 3
|
||||||
|
)
|
||||||
|
RETURNING id, bird_id, weight_grams, recorded_on::text, notes`,
|
||||||
|
[weightId, birdId, weightGrams, recordedOn, notes],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
export const listVetVisitsForBird = async (birdId: string, workspaceId: number) => {
|
export const listVetVisitsForBird = async (birdId: string, workspaceId: number) => {
|
||||||
const result = await db.query<VetVisitRow>(
|
const result = await db.query<VetVisitRow>(
|
||||||
`SELECT id, bird_id, visited_on::text, clinic_name, reason, notes
|
`SELECT id, bird_id, visited_on::text, clinic_name, reason, notes
|
||||||
@@ -886,7 +1142,7 @@ export const deleteVetVisitForBird = async (visitId: string, birdId: string) =>
|
|||||||
|
|
||||||
export const listMedicationsForBird = async (birdId: string, workspaceId: number) => {
|
export const listMedicationsForBird = async (birdId: string, workspaceId: number) => {
|
||||||
const result = await db.query<MedicationRow>(
|
const result = await db.query<MedicationRow>(
|
||||||
`SELECT id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes
|
`SELECT id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes, reminders_enabled
|
||||||
FROM medications
|
FROM medications
|
||||||
WHERE bird_id = $1
|
WHERE bird_id = $1
|
||||||
AND EXISTS (
|
AND EXISTS (
|
||||||
@@ -912,12 +1168,13 @@ export const createMedicationForBird = async (
|
|||||||
startDate: string,
|
startDate: string,
|
||||||
endDate: string | null,
|
endDate: string | null,
|
||||||
notes: string | null,
|
notes: string | null,
|
||||||
|
remindersEnabled: boolean,
|
||||||
) => {
|
) => {
|
||||||
const result = await db.query<MedicationRow>(
|
const result = await db.query<MedicationRow>(
|
||||||
`INSERT INTO medications (bird_id, name, dosage, frequency, dose_schedule, route, start_date, end_date, notes)
|
`INSERT INTO medications (bird_id, name, dosage, frequency, dose_schedule, route, start_date, end_date, notes, reminders_enabled)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes`,
|
RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes, reminders_enabled`,
|
||||||
[birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, notes],
|
[birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, notes, remindersEnabled],
|
||||||
);
|
);
|
||||||
|
|
||||||
return result.rows[0] ?? null;
|
return result.rows[0] ?? null;
|
||||||
@@ -934,6 +1191,7 @@ export const updateMedicationForBird = async (
|
|||||||
startDate: string,
|
startDate: string,
|
||||||
endDate: string | null,
|
endDate: string | null,
|
||||||
notes: string | null,
|
notes: string | null,
|
||||||
|
remindersEnabled: boolean,
|
||||||
) => {
|
) => {
|
||||||
const result = await db.query<MedicationRow>(
|
const result = await db.query<MedicationRow>(
|
||||||
`UPDATE medications
|
`UPDATE medications
|
||||||
@@ -944,11 +1202,12 @@ export const updateMedicationForBird = async (
|
|||||||
route = $7,
|
route = $7,
|
||||||
start_date = $8,
|
start_date = $8,
|
||||||
end_date = $9,
|
end_date = $9,
|
||||||
notes = $10
|
notes = $10,
|
||||||
|
reminders_enabled = $11
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND bird_id = $2
|
AND bird_id = $2
|
||||||
RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes`,
|
RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes, reminders_enabled`,
|
||||||
[medicationId, birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, notes],
|
[medicationId, birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, notes, remindersEnabled],
|
||||||
);
|
);
|
||||||
|
|
||||||
return result.rows[0] ?? null;
|
return result.rows[0] ?? null;
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { db } from '../db/client.js';
|
||||||
|
import type { DailyEducationQuestion, DailyEducationRow, EducationQuestionRow } from '../types.js';
|
||||||
|
|
||||||
|
export const getEducationOptOut = async (userId: string) => {
|
||||||
|
const result = await db.query<{ education_opt_out: boolean }>(
|
||||||
|
`SELECT education_opt_out
|
||||||
|
FROM users
|
||||||
|
WHERE id = $1`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0]?.education_opt_out ?? false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateEducationOptOut = async (userId: string, educationOptOut: boolean) => {
|
||||||
|
const result = await db.query<{ education_opt_out: boolean }>(
|
||||||
|
`UPDATE users
|
||||||
|
SET education_opt_out = $2
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING education_opt_out`,
|
||||||
|
[userId, educationOptOut],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0]?.education_opt_out ?? educationOptOut;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDailyEducationForDate = async (publishDate?: string) => {
|
||||||
|
const result = publishDate
|
||||||
|
? await db.query<DailyEducationRow>(
|
||||||
|
`SELECT id, publish_date::text, fact, quiz_questions, created_by_user_id, created_at, updated_at
|
||||||
|
FROM daily_education
|
||||||
|
WHERE publish_date = $1`,
|
||||||
|
[publishDate],
|
||||||
|
)
|
||||||
|
: await db.query<DailyEducationRow>(
|
||||||
|
`SELECT id, publish_date::text, fact, quiz_questions, created_by_user_id, created_at, updated_at
|
||||||
|
FROM daily_education
|
||||||
|
WHERE publish_date = CURRENT_DATE`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listDailyEducationForAdmin = async () => {
|
||||||
|
const result = await db.query<DailyEducationRow>(
|
||||||
|
`SELECT id, publish_date::text, fact, quiz_questions, created_by_user_id, created_at, updated_at
|
||||||
|
FROM daily_education
|
||||||
|
ORDER BY publish_date DESC
|
||||||
|
LIMIT 120`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const upsertDailyEducation = async ({
|
||||||
|
publishDate,
|
||||||
|
fact,
|
||||||
|
createdByUserId,
|
||||||
|
}: {
|
||||||
|
publishDate: string;
|
||||||
|
fact: string;
|
||||||
|
createdByUserId: string;
|
||||||
|
}) => {
|
||||||
|
const result = await db.query<DailyEducationRow>(
|
||||||
|
`INSERT INTO daily_education (publish_date, fact, created_by_user_id)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (publish_date) DO UPDATE
|
||||||
|
SET fact = EXCLUDED.fact,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
RETURNING id, publish_date::text, fact, quiz_questions, created_by_user_id, created_at, updated_at`,
|
||||||
|
[publishDate, fact, createdByUserId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listEducationQuestionsForAdmin = async () => {
|
||||||
|
const result = await db.query<EducationQuestionRow>(
|
||||||
|
`SELECT id, prompt, options, correct_answer_index, explanation, created_by_user_id, created_at, updated_at
|
||||||
|
FROM education_question_bank
|
||||||
|
ORDER BY updated_at DESC, created_at DESC
|
||||||
|
LIMIT 400`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listDailyEducationQuestions = async (seedDate?: string) => {
|
||||||
|
const result = await db.query<EducationQuestionRow>(
|
||||||
|
`SELECT id, prompt, options, correct_answer_index, explanation, created_by_user_id, created_at, updated_at
|
||||||
|
FROM education_question_bank
|
||||||
|
ORDER BY md5(COALESCE($1::text, CURRENT_DATE::text) || id::text)
|
||||||
|
LIMIT 4`,
|
||||||
|
[seedDate ?? null],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createEducationQuestion = async ({
|
||||||
|
question,
|
||||||
|
createdByUserId,
|
||||||
|
}: {
|
||||||
|
question: DailyEducationQuestion;
|
||||||
|
createdByUserId: string;
|
||||||
|
}) => {
|
||||||
|
const result = await db.query<EducationQuestionRow>(
|
||||||
|
`INSERT INTO education_question_bank (prompt, options, correct_answer_index, explanation, created_by_user_id)
|
||||||
|
VALUES ($1, $2::jsonb, $3, $4, $5)
|
||||||
|
RETURNING id, prompt, options, correct_answer_index, explanation, created_by_user_id, created_at, updated_at`,
|
||||||
|
[question.prompt, JSON.stringify(question.options), question.correctAnswerIndex, question.explanation, createdByUserId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateEducationQuestion = async (questionId: string, question: DailyEducationQuestion) => {
|
||||||
|
const result = await db.query<EducationQuestionRow>(
|
||||||
|
`UPDATE education_question_bank
|
||||||
|
SET prompt = $2,
|
||||||
|
options = $3::jsonb,
|
||||||
|
correct_answer_index = $4,
|
||||||
|
explanation = $5,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, prompt, options, correct_answer_index, explanation, created_by_user_id, created_at, updated_at`,
|
||||||
|
[questionId, question.prompt, JSON.stringify(question.options), question.correctAnswerIndex, question.explanation],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteEducationQuestion = async (questionId: string) => {
|
||||||
|
const result = await db.query<{ id: string }>(
|
||||||
|
`DELETE FROM education_question_bank
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id`,
|
||||||
|
[questionId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return Boolean(result.rowCount);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteDailyEducation = async (educationId: string) => {
|
||||||
|
const result = await db.query<{ id: string }>(
|
||||||
|
`DELETE FROM daily_education
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id`,
|
||||||
|
[educationId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return Boolean(result.rowCount);
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
createWorkspace,
|
createWorkspace,
|
||||||
|
deleteWorkspaceMember,
|
||||||
deleteWorkspaceIfEmpty,
|
deleteWorkspaceIfEmpty,
|
||||||
ensureDefaultWorkspaceForUser,
|
ensureDefaultWorkspaceForUser,
|
||||||
ensurePersonalWorkspaceForUser,
|
ensurePersonalWorkspaceForUser,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
getPlatformAdminSummary,
|
getPlatformAdminSummary,
|
||||||
listOwnedWorkspacesByOwnerEmail,
|
listOwnedWorkspacesByOwnerEmail,
|
||||||
updateWorkspace,
|
updateWorkspace,
|
||||||
|
updateWorkspaceMemberRole,
|
||||||
} from './workspaceRepository.js';
|
} from './workspaceRepository.js';
|
||||||
import { mockDb } from '../test/mockDb.js';
|
import { mockDb } from '../test/mockDb.js';
|
||||||
import type { UserRow } from '../types.js';
|
import type { UserRow } from '../types.js';
|
||||||
@@ -259,6 +261,263 @@ test('listOwnedWorkspacesByOwnerEmail resolves accepted owner flocks by email',
|
|||||||
assert.match(calls[0].text, /workspaces\.id <> \$2/);
|
assert.match(calls[0].text, /workspaces\.id <> \$2/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('updateWorkspaceMemberRole changes a non-owner member role', async () => {
|
||||||
|
const { calls } = mockDb({
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: 'member-1',
|
||||||
|
workspace_id: 42,
|
||||||
|
user_id: 'user-2',
|
||||||
|
invite_email: 'helper@example.com',
|
||||||
|
name: 'Helper',
|
||||||
|
role: 'viewer',
|
||||||
|
accepted_at: '2026-04-14T00:00:00.000Z',
|
||||||
|
created_at: '2026-04-14T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const member = await updateWorkspaceMemberRole({
|
||||||
|
memberId: 'member-1',
|
||||||
|
workspaceId: 42,
|
||||||
|
role: 'viewer',
|
||||||
|
requesterMemberId: 'owner-member',
|
||||||
|
requesterIsBillingOwner: false,
|
||||||
|
requesterRole: 'owner',
|
||||||
|
billingEmail: 'billing@example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(member?.role, 'viewer');
|
||||||
|
assert.deepEqual(calls[0].params, ['member-1', 42, 'viewer', false, 'owner-member', 'billing@example.com', 'owner']);
|
||||||
|
assert.match(calls[0].text, /UPDATE workspace_members/);
|
||||||
|
assert.match(calls[0].text, /role <> 'owner'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateWorkspaceMemberRole returns null when no non-owner member matches', async () => {
|
||||||
|
mockDb({
|
||||||
|
rowCount: 0,
|
||||||
|
rows: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const member = await updateWorkspaceMemberRole({
|
||||||
|
memberId: 'owner-member',
|
||||||
|
workspaceId: 42,
|
||||||
|
role: 'viewer',
|
||||||
|
requesterMemberId: 'owner-member',
|
||||||
|
requesterIsBillingOwner: false,
|
||||||
|
requesterRole: 'owner',
|
||||||
|
billingEmail: 'billing@example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(member, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateWorkspaceMemberRole lets the billing owner change another owner role', async () => {
|
||||||
|
const { calls } = mockDb({
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: 'other-owner',
|
||||||
|
workspace_id: 42,
|
||||||
|
user_id: 'user-2',
|
||||||
|
invite_email: 'other@example.com',
|
||||||
|
name: 'Other Owner',
|
||||||
|
role: 'assistant',
|
||||||
|
accepted_at: '2026-04-14T00:00:00.000Z',
|
||||||
|
created_at: '2026-04-14T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const member = await updateWorkspaceMemberRole({
|
||||||
|
memberId: 'other-owner',
|
||||||
|
workspaceId: 42,
|
||||||
|
role: 'assistant',
|
||||||
|
requesterMemberId: 'billing-owner',
|
||||||
|
requesterIsBillingOwner: true,
|
||||||
|
requesterRole: 'owner',
|
||||||
|
billingEmail: 'billing@example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(member?.role, 'assistant');
|
||||||
|
assert.deepEqual(calls[0].params, ['other-owner', 42, 'assistant', true, 'billing-owner', 'billing@example.com', 'owner']);
|
||||||
|
assert.match(calls[0].text, /id <> \$5/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateWorkspaceMemberRole does not let the billing owner change their own owner role', async () => {
|
||||||
|
mockDb({
|
||||||
|
rowCount: 0,
|
||||||
|
rows: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const member = await updateWorkspaceMemberRole({
|
||||||
|
memberId: 'billing-owner',
|
||||||
|
workspaceId: 42,
|
||||||
|
role: 'assistant',
|
||||||
|
requesterMemberId: 'billing-owner',
|
||||||
|
requesterIsBillingOwner: true,
|
||||||
|
requesterRole: 'owner',
|
||||||
|
billingEmail: 'billing@example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(member, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateWorkspaceMemberRole lets a non-billing owner change another non-billing owner role', async () => {
|
||||||
|
const { calls } = mockDb({
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: 'other-owner',
|
||||||
|
workspace_id: 42,
|
||||||
|
user_id: 'user-2',
|
||||||
|
invite_email: 'other@example.com',
|
||||||
|
name: 'Other Owner',
|
||||||
|
role: 'assistant',
|
||||||
|
accepted_at: '2026-04-14T00:00:00.000Z',
|
||||||
|
created_at: '2026-04-14T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const member = await updateWorkspaceMemberRole({
|
||||||
|
memberId: 'other-owner',
|
||||||
|
workspaceId: 42,
|
||||||
|
role: 'assistant',
|
||||||
|
requesterMemberId: 'non-billing-owner',
|
||||||
|
requesterIsBillingOwner: false,
|
||||||
|
requesterRole: 'owner',
|
||||||
|
billingEmail: 'billing@example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(member?.role, 'assistant');
|
||||||
|
assert.deepEqual(calls[0].params, ['other-owner', 42, 'assistant', false, 'non-billing-owner', 'billing@example.com', 'owner']);
|
||||||
|
assert.match(calls[0].text, /LOWER\(BTRIM\(COALESCE\(invite_email, email\)\)\) <> LOWER\(BTRIM\(\$6\)\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateWorkspaceMemberRole does not let a non-billing owner change the billing owner role', async () => {
|
||||||
|
mockDb({
|
||||||
|
rowCount: 0,
|
||||||
|
rows: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const member = await updateWorkspaceMemberRole({
|
||||||
|
memberId: 'billing-owner',
|
||||||
|
workspaceId: 42,
|
||||||
|
role: 'assistant',
|
||||||
|
requesterMemberId: 'non-billing-owner',
|
||||||
|
requesterIsBillingOwner: false,
|
||||||
|
requesterRole: 'owner',
|
||||||
|
billingEmail: 'billing@example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(member, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateWorkspaceMemberRole lets the billing owner promote a non-owner to owner', async () => {
|
||||||
|
const { calls } = mockDb({
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: 'member-1',
|
||||||
|
workspace_id: 42,
|
||||||
|
user_id: 'user-2',
|
||||||
|
invite_email: 'helper@example.com',
|
||||||
|
name: 'Helper',
|
||||||
|
role: 'owner',
|
||||||
|
accepted_at: '2026-04-14T00:00:00.000Z',
|
||||||
|
created_at: '2026-04-14T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const member = await updateWorkspaceMemberRole({
|
||||||
|
memberId: 'member-1',
|
||||||
|
workspaceId: 42,
|
||||||
|
role: 'owner',
|
||||||
|
requesterMemberId: 'billing-owner',
|
||||||
|
requesterIsBillingOwner: true,
|
||||||
|
requesterRole: 'owner',
|
||||||
|
billingEmail: 'billing@example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(member?.role, 'owner');
|
||||||
|
assert.deepEqual(calls[0].params, ['member-1', 42, 'owner', true, 'billing-owner', 'billing@example.com', 'owner']);
|
||||||
|
assert.match(calls[0].text, /\$3 <> 'owner'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateWorkspaceMemberRole does not let a non-billing owner promote a member to owner', async () => {
|
||||||
|
mockDb({
|
||||||
|
rowCount: 0,
|
||||||
|
rows: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const member = await updateWorkspaceMemberRole({
|
||||||
|
memberId: 'member-1',
|
||||||
|
workspaceId: 42,
|
||||||
|
role: 'owner',
|
||||||
|
requesterMemberId: 'non-billing-owner',
|
||||||
|
requesterIsBillingOwner: false,
|
||||||
|
requesterRole: 'owner',
|
||||||
|
billingEmail: 'billing@example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(member, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleteWorkspaceMember removes non-owner members without billing owner access', async () => {
|
||||||
|
const { calls } = mockDb({
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [{ id: 'member-1' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleted = await deleteWorkspaceMember({
|
||||||
|
memberId: 'member-1',
|
||||||
|
workspaceId: 42,
|
||||||
|
requesterMemberId: 'owner-member',
|
||||||
|
requesterIsBillingOwner: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(deleted, true);
|
||||||
|
assert.deepEqual(calls[0].params, ['member-1', 42, false, 'owner-member']);
|
||||||
|
assert.match(calls[0].text, /role <> 'owner'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleteWorkspaceMember lets the billing owner remove another owner', async () => {
|
||||||
|
const { calls } = mockDb({
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [{ id: 'other-owner' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleted = await deleteWorkspaceMember({
|
||||||
|
memberId: 'other-owner',
|
||||||
|
workspaceId: 42,
|
||||||
|
requesterMemberId: 'billing-owner',
|
||||||
|
requesterIsBillingOwner: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(deleted, true);
|
||||||
|
assert.deepEqual(calls[0].params, ['other-owner', 42, true, 'billing-owner']);
|
||||||
|
assert.match(calls[0].text, /id <> \$4/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleteWorkspaceMember does not let the billing owner remove their own owner membership', async () => {
|
||||||
|
mockDb({
|
||||||
|
rowCount: 0,
|
||||||
|
rows: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleted = await deleteWorkspaceMember({
|
||||||
|
memberId: 'billing-owner',
|
||||||
|
workspaceId: 42,
|
||||||
|
requesterMemberId: 'billing-owner',
|
||||||
|
requesterIsBillingOwner: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(deleted, false);
|
||||||
|
});
|
||||||
|
|
||||||
test('getPlatformAdminSummary counts memorialized birds separately', async () => {
|
test('getPlatformAdminSummary counts memorialized birds separately', async () => {
|
||||||
const { calls } = mockDb({
|
const { calls } = mockDb({
|
||||||
rowCount: 1,
|
rowCount: 1,
|
||||||
|
|||||||
@@ -364,19 +364,81 @@ export const upsertWorkspaceMember = async ({
|
|||||||
return result.rows[0] ?? null;
|
return result.rows[0] ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteWorkspaceMember = async (memberId: string, workspaceId: number) => {
|
export const deleteWorkspaceMember = async ({
|
||||||
|
memberId,
|
||||||
|
workspaceId,
|
||||||
|
requesterMemberId,
|
||||||
|
requesterIsBillingOwner,
|
||||||
|
}: {
|
||||||
|
memberId: string;
|
||||||
|
workspaceId: number;
|
||||||
|
requesterMemberId: string;
|
||||||
|
requesterIsBillingOwner: boolean;
|
||||||
|
}) => {
|
||||||
const result = await db.query<{ id: string }>(
|
const result = await db.query<{ id: string }>(
|
||||||
`DELETE FROM workspace_members
|
`DELETE FROM workspace_members
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND workspace_id = $2
|
AND workspace_id = $2
|
||||||
AND role <> 'owner'
|
AND (
|
||||||
|
role <> 'owner'
|
||||||
|
OR (
|
||||||
|
$3 = TRUE
|
||||||
|
AND id <> $4
|
||||||
|
)
|
||||||
|
)
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[memberId, workspaceId],
|
[memberId, workspaceId, requesterIsBillingOwner, requesterMemberId],
|
||||||
);
|
);
|
||||||
|
|
||||||
return Boolean(result.rowCount);
|
return Boolean(result.rowCount);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const updateWorkspaceMemberRole = async ({
|
||||||
|
memberId,
|
||||||
|
workspaceId,
|
||||||
|
role,
|
||||||
|
requesterMemberId,
|
||||||
|
requesterIsBillingOwner,
|
||||||
|
requesterRole,
|
||||||
|
billingEmail,
|
||||||
|
}: {
|
||||||
|
memberId: string;
|
||||||
|
workspaceId: number;
|
||||||
|
role: WorkspaceMemberRow['role'];
|
||||||
|
requesterMemberId: string;
|
||||||
|
requesterIsBillingOwner: boolean;
|
||||||
|
requesterRole: WorkspaceMemberRow['role'];
|
||||||
|
billingEmail: string;
|
||||||
|
}) => {
|
||||||
|
const result = await db.query<WorkspaceMemberRow>(
|
||||||
|
`UPDATE workspace_members
|
||||||
|
SET role = $3
|
||||||
|
WHERE id = $1
|
||||||
|
AND workspace_id = $2
|
||||||
|
AND (
|
||||||
|
$3 <> 'owner'
|
||||||
|
OR $4 = TRUE
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
role <> 'owner'
|
||||||
|
OR (
|
||||||
|
id <> $5
|
||||||
|
AND (
|
||||||
|
$4 = TRUE
|
||||||
|
OR (
|
||||||
|
$7 = 'owner'
|
||||||
|
AND LOWER(BTRIM(COALESCE(invite_email, email))) <> LOWER(BTRIM($6))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
RETURNING id, workspace_id, user_id, COALESCE(invite_email, email) AS invite_email, name, role, accepted_at::text, created_at`,
|
||||||
|
[memberId, workspaceId, role, requesterIsBillingOwner, requesterMemberId, billingEmail, requesterRole],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
export const listRescueWorkspacesForAdmin = async () => {
|
export const listRescueWorkspacesForAdmin = async () => {
|
||||||
const result = await db.query<
|
const result = await db.query<
|
||||||
WorkspaceRow & {
|
WorkspaceRow & {
|
||||||
|
|||||||
+80
-1
@@ -6,16 +6,45 @@ export type SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled'
|
|||||||
export type RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejected';
|
export type RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejected';
|
||||||
export type ProviderKey = 'google' | 'microsoft' | 'apple';
|
export type ProviderKey = 'google' | 'microsoft' | 'apple';
|
||||||
export type IntegrationTokenScope = 'read_only' | 'read_write';
|
export type IntegrationTokenScope = 'read_only' | 'read_write';
|
||||||
export type BirdGender = 'unknown' | 'male' | 'female';
|
export type BirdGender = 'unknown' | 'male' | 'female' | 'male_dna' | 'female_dna';
|
||||||
|
|
||||||
export type UserRow = {
|
export type UserRow = {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
password_hash: string | null;
|
password_hash: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
|
education_opt_out?: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DailyEducationQuestion = {
|
||||||
|
prompt: string;
|
||||||
|
options: string[];
|
||||||
|
correctAnswerIndex: number;
|
||||||
|
explanation: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DailyEducationRow = {
|
||||||
|
id: string;
|
||||||
|
publish_date: string;
|
||||||
|
fact: string;
|
||||||
|
quiz_questions: DailyEducationQuestion[];
|
||||||
|
created_by_user_id: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EducationQuestionRow = {
|
||||||
|
id: string;
|
||||||
|
prompt: string;
|
||||||
|
options: string[];
|
||||||
|
correct_answer_index: number;
|
||||||
|
explanation: string | null;
|
||||||
|
created_by_user_id: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type WorkspaceRow = {
|
export type WorkspaceRow = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -101,6 +130,8 @@ export type BirdRow = {
|
|||||||
motivators: string | null;
|
motivators: string | null;
|
||||||
demotivators: string | null;
|
demotivators: string | null;
|
||||||
favorite_snack: string | null;
|
favorite_snack: string | null;
|
||||||
|
location_label: string | null;
|
||||||
|
location_details: Record<string, unknown> | null;
|
||||||
vet_clinic_name: string | null;
|
vet_clinic_name: string | null;
|
||||||
vet_clinic_address: string | null;
|
vet_clinic_address: string | null;
|
||||||
vet_account_number: string | null;
|
vet_account_number: string | null;
|
||||||
@@ -174,6 +205,26 @@ export type BirdTransferCodeRow = {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type BirdTimelineEventType = 'profile_created' | 'transferred' | 'location_updated' | 'owner_changed' | 'manual_note';
|
||||||
|
|
||||||
|
export type BirdTimelineEventRow = {
|
||||||
|
id: string;
|
||||||
|
bird_id: string;
|
||||||
|
event_type: BirdTimelineEventType;
|
||||||
|
from_workspace_id: number | null;
|
||||||
|
to_workspace_id: number | null;
|
||||||
|
from_workspace_name: string | null;
|
||||||
|
to_workspace_name: string | null;
|
||||||
|
from_owner_email: string | null;
|
||||||
|
to_owner_email: string | null;
|
||||||
|
location_label: string | null;
|
||||||
|
location_details: Record<string, unknown> | null;
|
||||||
|
note: string | null;
|
||||||
|
event_date: string;
|
||||||
|
created_by_user_id: string | null;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type WeightRow = {
|
export type WeightRow = {
|
||||||
id: string;
|
id: string;
|
||||||
bird_id: string;
|
bird_id: string;
|
||||||
@@ -202,6 +253,7 @@ export type MedicationRow = {
|
|||||||
start_date: string;
|
start_date: string;
|
||||||
end_date: string | null;
|
end_date: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
|
reminders_enabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MedicationDoseScheduleItem = {
|
export type MedicationDoseScheduleItem = {
|
||||||
@@ -210,6 +262,33 @@ export type MedicationDoseScheduleItem = {
|
|||||||
time: string;
|
time: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MedicationReminderCandidateRow = BirdRow & {
|
||||||
|
workspace_name: string;
|
||||||
|
medication_id: string;
|
||||||
|
medication_name: string;
|
||||||
|
dosage: string;
|
||||||
|
frequency: string;
|
||||||
|
dose_schedule: MedicationDoseScheduleItem[];
|
||||||
|
route: string | null;
|
||||||
|
medication_start_date: string;
|
||||||
|
medication_end_date: string | null;
|
||||||
|
medication_notes: string | null;
|
||||||
|
scheduled_on: string;
|
||||||
|
administration_slot: string;
|
||||||
|
administration_label: string;
|
||||||
|
administration_time: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MedicationReminderDeliveryRow = {
|
||||||
|
id: string;
|
||||||
|
medication_id: string;
|
||||||
|
bird_id: string;
|
||||||
|
workspace_id: number;
|
||||||
|
scheduled_on: string;
|
||||||
|
administration_slot: string;
|
||||||
|
delivered_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type MedicationAdministrationRow = {
|
export type MedicationAdministrationRow = {
|
||||||
id: string;
|
id: string;
|
||||||
medication_id: string;
|
medication_id: string;
|
||||||
|
|||||||
+64
-1
@@ -2,16 +2,36 @@ import { Worker } from 'bullmq';
|
|||||||
|
|
||||||
import { ensureSchema } from './db/schema.js';
|
import { ensureSchema } from './db/schema.js';
|
||||||
import { db } from './db/client.js';
|
import { db } from './db/client.js';
|
||||||
import { runBirdMilestoneReminders, startBirdMilestoneReminderScheduler } from './app.js';
|
import {
|
||||||
|
runBirdMilestoneReminders,
|
||||||
|
runMedicationReminders,
|
||||||
|
startBirdMilestoneReminderScheduler,
|
||||||
|
startMedicationReminderScheduler,
|
||||||
|
} from './app.js';
|
||||||
|
import {
|
||||||
|
adoptionReportQueueName,
|
||||||
|
closeAdoptionReportQueue,
|
||||||
|
type AdoptionReportJobData,
|
||||||
|
type AdoptionReportJobResult,
|
||||||
|
} from './queues/adoptionReportQueue.js';
|
||||||
import {
|
import {
|
||||||
birdMilestoneReminderQueueName,
|
birdMilestoneReminderQueueName,
|
||||||
closeBirdMilestoneReminderQueue,
|
closeBirdMilestoneReminderQueue,
|
||||||
type BirdMilestoneReminderJobData,
|
type BirdMilestoneReminderJobData,
|
||||||
type BirdMilestoneReminderJobResult,
|
type BirdMilestoneReminderJobResult,
|
||||||
} from './queues/birdMilestoneReminderQueue.js';
|
} from './queues/birdMilestoneReminderQueue.js';
|
||||||
|
import {
|
||||||
|
closeMedicationReminderQueue,
|
||||||
|
medicationReminderQueueName,
|
||||||
|
type MedicationReminderJobData,
|
||||||
|
type MedicationReminderJobResult,
|
||||||
|
} from './queues/medicationReminderQueue.js';
|
||||||
import { redisConnection } from './queues/redisConnection.js';
|
import { redisConnection } from './queues/redisConnection.js';
|
||||||
|
import { renderAdoptionReportForBird } from './reports/adoptionReportJob.js';
|
||||||
|
|
||||||
let birdMilestoneWorker: Worker<BirdMilestoneReminderJobData, BirdMilestoneReminderJobResult> | null = null;
|
let birdMilestoneWorker: Worker<BirdMilestoneReminderJobData, BirdMilestoneReminderJobResult> | null = null;
|
||||||
|
let medicationReminderWorker: Worker<MedicationReminderJobData, MedicationReminderJobResult> | null = null;
|
||||||
|
let adoptionReportWorker: Worker<AdoptionReportJobData, AdoptionReportJobResult> | null = null;
|
||||||
|
|
||||||
const startWorker = async () => {
|
const startWorker = async () => {
|
||||||
await ensureSchema();
|
await ensureSchema();
|
||||||
@@ -35,14 +55,57 @@ const startWorker = async () => {
|
|||||||
console.error(`Bird milestone reminder job failed: id=${job?.id ?? 'unknown'}`, error);
|
console.error(`Bird milestone reminder job failed: id=${job?.id ?? 'unknown'}`, error);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
medicationReminderWorker = new Worker<MedicationReminderJobData, MedicationReminderJobResult>(
|
||||||
|
medicationReminderQueueName,
|
||||||
|
async (job) => {
|
||||||
|
const result = await runMedicationReminders(job.data.runDate, job.data.currentTime);
|
||||||
|
console.log(
|
||||||
|
`Medication reminder job completed for ${result.runDate} ${result.currentTime}: checked=${result.checked}, sent=${result.sent}, skipped=${result.skipped}, failed=${result.failed}`,
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
connection: redisConnection,
|
||||||
|
concurrency: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
medicationReminderWorker.on('failed', (job, error) => {
|
||||||
|
console.error(`Medication reminder job failed: id=${job?.id ?? 'unknown'}`, error);
|
||||||
|
});
|
||||||
|
|
||||||
|
adoptionReportWorker = new Worker<AdoptionReportJobData, AdoptionReportJobResult>(
|
||||||
|
adoptionReportQueueName,
|
||||||
|
async (job) => {
|
||||||
|
const pdf = await renderAdoptionReportForBird(job.data);
|
||||||
|
console.log(`Adoption report job completed: id=${job.id ?? 'unknown'}, birdId=${job.data.birdId}, bytes=${pdf.length}`);
|
||||||
|
return {
|
||||||
|
pdfBase64: pdf.toString('base64'),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{
|
||||||
|
connection: redisConnection,
|
||||||
|
concurrency: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
adoptionReportWorker.on('failed', (job, error) => {
|
||||||
|
console.error(`Adoption report job failed: id=${job?.id ?? 'unknown'}, birdId=${job?.data.birdId ?? 'unknown'}`, error);
|
||||||
|
});
|
||||||
|
|
||||||
startBirdMilestoneReminderScheduler();
|
startBirdMilestoneReminderScheduler();
|
||||||
|
startMedicationReminderScheduler();
|
||||||
console.log('FlockPal worker started.');
|
console.log('FlockPal worker started.');
|
||||||
};
|
};
|
||||||
|
|
||||||
const shutdown = async (signal: string) => {
|
const shutdown = async (signal: string) => {
|
||||||
console.log(`FlockPal worker received ${signal}; shutting down.`);
|
console.log(`FlockPal worker received ${signal}; shutting down.`);
|
||||||
await birdMilestoneWorker?.close();
|
await birdMilestoneWorker?.close();
|
||||||
|
await medicationReminderWorker?.close();
|
||||||
|
await adoptionReportWorker?.close();
|
||||||
await closeBirdMilestoneReminderQueue();
|
await closeBirdMilestoneReminderQueue();
|
||||||
|
await closeMedicationReminderQueue();
|
||||||
|
await closeAdoptionReportQueue();
|
||||||
await db.close();
|
await db.close();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ services:
|
|||||||
POSTGRES_USER: ${POSTGRES_USER:-flockpal}
|
POSTGRES_USER: ${POSTGRES_USER:-flockpal}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD for production}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD for production}
|
||||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
||||||
|
ADOPTION_REPORT_RENDER_TIMEOUT_MS: ${ADOPTION_REPORT_RENDER_TIMEOUT_MS:-45000}
|
||||||
IMAGE_STORAGE_PROVIDER: ${IMAGE_STORAGE_PROVIDER:-database}
|
IMAGE_STORAGE_PROVIDER: ${IMAGE_STORAGE_PROVIDER:-database}
|
||||||
S3_ENDPOINT: ${S3_ENDPOINT:-}
|
S3_ENDPOINT: ${S3_ENDPOINT:-}
|
||||||
S3_REGION: ${S3_REGION:-}
|
S3_REGION: ${S3_REGION:-}
|
||||||
@@ -54,10 +55,12 @@ services:
|
|||||||
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
|
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
|
||||||
FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production}
|
FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production}
|
||||||
BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production}
|
BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production}
|
||||||
|
MAPBOX_ACCESS_TOKEN: ${MAPBOX_ACCESS_TOKEN:-}
|
||||||
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
||||||
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
||||||
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
||||||
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
||||||
|
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
|
||||||
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
||||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
||||||
@@ -134,10 +137,12 @@ services:
|
|||||||
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
|
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
|
||||||
FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production}
|
FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production}
|
||||||
BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production}
|
BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production}
|
||||||
|
MAPBOX_ACCESS_TOKEN: ${MAPBOX_ACCESS_TOKEN:-}
|
||||||
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
||||||
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
||||||
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
||||||
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
||||||
|
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
|
||||||
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
||||||
SMTP_HOST: ${SMTP_HOST:-}
|
SMTP_HOST: ${SMTP_HOST:-}
|
||||||
SMTP_PORT: ${SMTP_PORT:-587}
|
SMTP_PORT: ${SMTP_PORT:-587}
|
||||||
|
|||||||
+6
-1
@@ -41,6 +41,7 @@ services:
|
|||||||
POSTGRES_USER: ${POSTGRES_USER:-flockpal}
|
POSTGRES_USER: ${POSTGRES_USER:-flockpal}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flockpal_dev_password}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flockpal_dev_password}
|
||||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
||||||
|
ADOPTION_REPORT_RENDER_TIMEOUT_MS: ${ADOPTION_REPORT_RENDER_TIMEOUT_MS:-45000}
|
||||||
IMAGE_STORAGE_PROVIDER: ${IMAGE_STORAGE_PROVIDER:-database}
|
IMAGE_STORAGE_PROVIDER: ${IMAGE_STORAGE_PROVIDER:-database}
|
||||||
S3_ENDPOINT: ${S3_ENDPOINT:-}
|
S3_ENDPOINT: ${S3_ENDPOINT:-}
|
||||||
S3_REGION: ${S3_REGION:-}
|
S3_REGION: ${S3_REGION:-}
|
||||||
@@ -52,10 +53,12 @@ services:
|
|||||||
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
|
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
|
||||||
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
|
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
|
||||||
BACKEND_URL: ${BACKEND_URL:-http://localhost:5000}
|
BACKEND_URL: ${BACKEND_URL:-http://localhost:5000}
|
||||||
|
MAPBOX_ACCESS_TOKEN: ${MAPBOX_ACCESS_TOKEN:-}
|
||||||
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
||||||
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
||||||
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
||||||
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
||||||
|
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
|
||||||
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
||||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
||||||
@@ -127,10 +130,12 @@ services:
|
|||||||
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
|
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
|
||||||
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
|
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
|
||||||
BACKEND_URL: ${BACKEND_URL:-http://localhost:5000}
|
BACKEND_URL: ${BACKEND_URL:-http://localhost:5000}
|
||||||
|
MAPBOX_ACCESS_TOKEN: ${MAPBOX_ACCESS_TOKEN:-}
|
||||||
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
||||||
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
||||||
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
||||||
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
||||||
|
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
|
||||||
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
||||||
SMTP_HOST: ${SMTP_HOST:-}
|
SMTP_HOST: ${SMTP_HOST:-}
|
||||||
SMTP_PORT: ${SMTP_PORT:-587}
|
SMTP_PORT: ${SMTP_PORT:-587}
|
||||||
@@ -157,7 +162,7 @@ services:
|
|||||||
dockerfile: Dockerfile.dev
|
dockerfile: Dockerfile.dev
|
||||||
container_name: flockpal-frontend
|
container_name: flockpal-frontend
|
||||||
environment:
|
environment:
|
||||||
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-http://localhost:5000/api}
|
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-/api}
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ Role requirements are called out per endpoint below. If the signed-in member lac
|
|||||||
"vetClinicAddress": "123 Feather Lane, Raleigh, NC",
|
"vetClinicAddress": "123 Feather Lane, Raleigh, NC",
|
||||||
"vetAccountNumber": "FP-1001",
|
"vetAccountNumber": "FP-1001",
|
||||||
"vetDoctorName": "Dr. Rivera",
|
"vetDoctorName": "Dr. Rivera",
|
||||||
"gender": "female",
|
"gender": "female_dna",
|
||||||
"dateOfBirth": "2023-05-10",
|
"dateOfBirth": "2023-05-10",
|
||||||
"gotchaDay": "2023-08-21",
|
"gotchaDay": "2023-08-21",
|
||||||
"chartColor": "#cb3a35",
|
"chartColor": "#cb3a35",
|
||||||
@@ -299,7 +299,7 @@ Role requirements are called out per endpoint below. If the signed-in member lac
|
|||||||
- Dates use `YYYY-MM-DD`
|
- Dates use `YYYY-MM-DD`
|
||||||
- `workspaceType` is `standard` or `rescue`
|
- `workspaceType` is `standard` or `rescue`
|
||||||
- member `role` is `owner`, `assistant`, `caregiver`, or `viewer`
|
- member `role` is `owner`, `assistant`, `caregiver`, or `viewer`
|
||||||
- bird `gender` is `unknown`, `male`, or `female`
|
- bird `gender` is `unknown`, `male`, `female`, `male_dna`, or `female_dna`; `male` and `female` indicate assumed sex
|
||||||
- bird `chartColor` must be a `#RRGGBB` hex color
|
- bird `chartColor` must be a `#RRGGBB` hex color
|
||||||
- `photoDataUrl` must be a base64 `data:image/...` URL
|
- `photoDataUrl` must be a base64 `data:image/...` URL
|
||||||
- `weightGrams` must be a positive number up to `10000`
|
- `weightGrams` must be a positive number up to `10000`
|
||||||
@@ -801,7 +801,7 @@ Request body:
|
|||||||
"vetClinicAddress": "123 Feather Lane, Raleigh, NC",
|
"vetClinicAddress": "123 Feather Lane, Raleigh, NC",
|
||||||
"vetAccountNumber": "FP-1001",
|
"vetAccountNumber": "FP-1001",
|
||||||
"vetDoctorName": "Dr. Rivera",
|
"vetDoctorName": "Dr. Rivera",
|
||||||
"gender": "female",
|
"gender": "female_dna",
|
||||||
"dateOfBirth": "2023-05-10",
|
"dateOfBirth": "2023-05-10",
|
||||||
"gotchaDay": "2023-08-21",
|
"gotchaDay": "2023-08-21",
|
||||||
"chartColor": "#cb3a35",
|
"chartColor": "#cb3a35",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ RUN npm ci
|
|||||||
COPY tsconfig*.json ./
|
COPY tsconfig*.json ./
|
||||||
COPY vite.config.ts ./
|
COPY vite.config.ts ./
|
||||||
COPY index.html ./
|
COPY index.html ./
|
||||||
|
COPY public ./public
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ RUN npm install
|
|||||||
COPY tsconfig*.json ./
|
COPY tsconfig*.json ./
|
||||||
COPY vite.config.ts ./
|
COPY vite.config.ts ./
|
||||||
COPY index.html ./
|
COPY index.html ./
|
||||||
|
COPY public ./public
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["npm", "run", "dev", "--", "--host"]
|
CMD ["npm", "run", "dev", "--", "--host"]
|
||||||
|
|||||||
+1891
-751
File diff suppressed because it is too large
Load Diff
+570
-15
@@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
--ink: #1f2a2a;
|
--ink: #1f2a2a;
|
||||||
--muted: #5d5f59;
|
--muted: #5d5f59;
|
||||||
@@ -616,14 +617,6 @@ textarea {
|
|||||||
break-inside: avoid;
|
break-inside: avoid;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-card-bird-profiles {
|
|
||||||
order: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-card-bird-profiles[hidden] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-card-collaborators {
|
.settings-card-collaborators {
|
||||||
order: 2;
|
order: 2;
|
||||||
}
|
}
|
||||||
@@ -750,6 +743,123 @@ 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;
|
||||||
@@ -883,10 +993,19 @@ textarea {
|
|||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bird-card-title .bird-card-gender-cluster {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.28rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.gender-inline {
|
.gender-inline {
|
||||||
font-size: 1.2rem;
|
font-size: 1.45rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gender-inline.male {
|
.gender-inline.male {
|
||||||
@@ -901,6 +1020,99 @@ textarea {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gender-source-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.45rem;
|
||||||
|
height: 1.45rem;
|
||||||
|
flex: 0 0 1.45rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-source-icon svg {
|
||||||
|
width: 1.28rem;
|
||||||
|
height: 1.28rem;
|
||||||
|
fill: none;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-source-icon.dna {
|
||||||
|
width: 1.9rem;
|
||||||
|
height: 1.9rem;
|
||||||
|
flex-basis: 1.9rem;
|
||||||
|
background: rgba(91, 74, 161, 0.1);
|
||||||
|
color: #5b4aa1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-source-icon.dna svg {
|
||||||
|
width: 1.72rem;
|
||||||
|
height: 1.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-source-icon .dna-ring {
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-source-icon .dna-strand {
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-source-icon .dna-rung {
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.05;
|
||||||
|
opacity: 0.82;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-source-icon .dna-dot {
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-source-icon.assumed {
|
||||||
|
width: 1.9rem;
|
||||||
|
height: 1.9rem;
|
||||||
|
flex-basis: 1.9rem;
|
||||||
|
background: rgba(93, 95, 89, 0.12);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-source-icon.assumed svg {
|
||||||
|
width: 1.72rem;
|
||||||
|
height: 1.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-source-icon .assumed-eye {
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-source-icon .assumed-pupil {
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-card-title .gender-source-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.05rem;
|
||||||
|
height: 1.05rem;
|
||||||
|
flex-basis: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-card-title .gender-source-icon svg {
|
||||||
|
display: block;
|
||||||
|
width: 0.92rem;
|
||||||
|
height: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
.bird-avatar,
|
.bird-avatar,
|
||||||
.profile-photo {
|
.profile-photo {
|
||||||
width: 56px;
|
width: 56px;
|
||||||
@@ -1113,6 +1325,264 @@ textarea {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bird-timeline-card {
|
||||||
|
grid-template-columns: 18px minmax(0, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
border: 1px solid rgba(39, 105, 179, 0.12);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.76);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-timeline-graph-card {
|
||||||
|
padding: 0.85rem;
|
||||||
|
border: 1px solid rgba(39, 105, 179, 0.12);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-timeline-graph {
|
||||||
|
position: relative;
|
||||||
|
min-height: 340px;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-timeline-graph-line {
|
||||||
|
position: absolute;
|
||||||
|
left: calc(8.125% + 18px);
|
||||||
|
right: 8.125%;
|
||||||
|
top: 50%;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--timeline-color, var(--accent-green));
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-timeline-graph-scale {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-timeline-graph-tick {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(50% + 78px);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-timeline-graph-tick::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: calc(100% + 0.35rem);
|
||||||
|
width: 1px;
|
||||||
|
height: 68px;
|
||||||
|
background: linear-gradient(to bottom, rgba(39, 105, 179, 0.22), rgba(39, 105, 179, 0));
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-timeline-graph-tick.today {
|
||||||
|
color: var(--accent-green);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-timeline-graph-tick.today::before {
|
||||||
|
background: linear-gradient(to bottom, rgba(35, 138, 90, 0.42), rgba(35, 138, 90, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-timeline-graph-point {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
width: 136px;
|
||||||
|
height: 0;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-timeline-graph-dot {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
z-index: 2;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-timeline-graph-point.above .bird-timeline-graph-dot {
|
||||||
|
left: calc(50% + var(--branch-offset, 0px));
|
||||||
|
bottom: var(--branch-distance, 34px);
|
||||||
|
transform: translate(-50%, 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-timeline-graph-point.below .bird-timeline-graph-dot {
|
||||||
|
left: calc(50% + var(--branch-offset, 0px));
|
||||||
|
top: var(--branch-distance, 34px);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-timeline-graph-point.on-line .bird-timeline-graph-dot {
|
||||||
|
top: 0;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.bird-timeline-graph-point.hatch_date .bird-timeline-graph-dot {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fffdf9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-timeline-graph-connector {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
width: 2px;
|
||||||
|
height: var(--branch-connector-length, var(--branch-distance, 34px));
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgba(39, 105, 179, 0.18) 0 4px,
|
||||||
|
transparent 4px 9px
|
||||||
|
);
|
||||||
|
transform: translateX(-50%) rotate(var(--branch-angle, 0deg));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-timeline-graph-point.above .bird-timeline-graph-connector {
|
||||||
|
bottom: 0;
|
||||||
|
transform-origin: bottom center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-timeline-graph-point.below .bird-timeline-graph-connector {
|
||||||
|
top: 0;
|
||||||
|
transform-origin: top center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-timeline-graph-point.on-line .bird-timeline-graph-connector {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-timeline-graph-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
color: var(--accent-green);
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-timeline-graph-point.hatch_date .bird-timeline-graph-icon {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
color: var(--accent-gold);
|
||||||
|
font-size: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-timeline-graph-point.owner_changed .bird-timeline-graph-icon {
|
||||||
|
color: var(--accent-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-timeline-graph-point.transferred .bird-timeline-graph-icon {
|
||||||
|
color: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-timeline-graph-copy {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
width: 124px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: grid;
|
||||||
|
gap: 0.08rem;
|
||||||
|
justify-items: center;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-timeline-graph-copy strong {
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.15;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-timeline-graph-copy span {
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-timeline-graph-point.above .bird-timeline-graph-copy {
|
||||||
|
left: calc(50% + var(--branch-offset, 0px));
|
||||||
|
bottom: calc(var(--branch-distance, 34px) + 24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-timeline-graph-point.below .bird-timeline-graph-copy {
|
||||||
|
left: calc(50% + var(--branch-offset, 0px));
|
||||||
|
top: calc(var(--branch-distance, 34px) + 24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-timeline-graph-point.on-line .bird-timeline-graph-copy {
|
||||||
|
bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-timeline-form {
|
||||||
|
padding: 0.85rem;
|
||||||
|
border: 1px solid rgba(35, 138, 90, 0.14);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(240, 248, 244, 0.54);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-timeline-marker {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--accent-green);
|
||||||
|
box-shadow: 0 0 0 4px rgba(35, 138, 90, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-timeline-content {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.3rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-timeline-content > div {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-timeline-content strong,
|
||||||
|
.bird-timeline-content span,
|
||||||
|
.bird-timeline-content small,
|
||||||
|
.bird-timeline-content p {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-timeline-content span,
|
||||||
|
.bird-timeline-content small {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-timeline-content p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
.legend-grid,
|
.legend-grid,
|
||||||
.detail-grid,
|
.detail-grid,
|
||||||
.summary-grid {
|
.summary-grid {
|
||||||
@@ -1212,6 +1682,7 @@ textarea {
|
|||||||
.bird-detail-tab .info-tab-icon,
|
.bird-detail-tab .info-tab-icon,
|
||||||
.bird-detail-tab .note-tab-icon,
|
.bird-detail-tab .note-tab-icon,
|
||||||
.bird-detail-tab .report-tab-icon,
|
.bird-detail-tab .report-tab-icon,
|
||||||
|
.bird-detail-tab .timeline-tab-icon,
|
||||||
.bird-detail-tab .audit-tab-icon,
|
.bird-detail-tab .audit-tab-icon,
|
||||||
.bird-detail-tab .vet-tab-icon {
|
.bird-detail-tab .vet-tab-icon {
|
||||||
width: 24px;
|
width: 24px;
|
||||||
@@ -1240,7 +1711,7 @@ textarea {
|
|||||||
|
|
||||||
.profile-copy {
|
.profile-copy {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.3rem;
|
gap: 0.18rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-copy h3 {
|
.profile-copy h3 {
|
||||||
@@ -1248,6 +1719,10 @@ textarea {
|
|||||||
font-size: 1.6rem;
|
font-size: 1.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-copy p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.profile-title {
|
.profile-title {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1258,14 +1733,26 @@ textarea {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-width: 1.9rem;
|
width: 1.9rem;
|
||||||
height: 1.9rem;
|
height: 1.9rem;
|
||||||
|
flex: 0 0 1.9rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 1.2rem;
|
font-size: 1.45rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gender-symbol-mark {
|
||||||
|
display: block;
|
||||||
|
line-height: 0.82;
|
||||||
|
transform: scale(1.16);
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-title .gender-symbol-mark {
|
||||||
|
transform: scale(1.28);
|
||||||
|
}
|
||||||
|
|
||||||
.gender-symbol.male {
|
.gender-symbol.male {
|
||||||
background: rgba(39, 105, 179, 0.12);
|
background: rgba(39, 105, 179, 0.12);
|
||||||
color: var(--accent-blue);
|
color: var(--accent-blue);
|
||||||
@@ -1288,7 +1775,7 @@ textarea {
|
|||||||
|
|
||||||
.segmented-control {
|
.segmented-control {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(8.5rem, 1fr));
|
||||||
gap: 0.55rem;
|
gap: 0.55rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1684,6 +2171,60 @@ label {
|
|||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.verified-location-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verified-location-label {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verified-location-search-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 0.65rem;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verified-location-search-row.has-selected-location {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verified-location-search-row.has-selected-location input {
|
||||||
|
border-color: rgba(35, 138, 90, 0.45);
|
||||||
|
background: rgba(35, 138, 90, 0.08);
|
||||||
|
box-shadow: 0 0 0 3px rgba(35, 138, 90, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verified-location-result small {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verified-location-results {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verified-location-result {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.15rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.8rem 0.9rem;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--ink);
|
||||||
|
border: 1px solid rgba(53, 129, 98, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verified-location-result:hover {
|
||||||
|
border-color: rgba(39, 105, 179, 0.28);
|
||||||
|
background: rgba(255, 255, 255, 0.78);
|
||||||
|
}
|
||||||
|
|
||||||
.toggle-card input[type="checkbox"] {
|
.toggle-card input[type="checkbox"] {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
@@ -2003,6 +2544,11 @@ 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,
|
||||||
@@ -2013,7 +2559,8 @@ label {
|
|||||||
.inline-form,
|
.inline-form,
|
||||||
.profile-hero,
|
.profile-hero,
|
||||||
.photo-editor,
|
.photo-editor,
|
||||||
.settings-nested-grid {
|
.settings-nested-grid,
|
||||||
|
.verified-location-search-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2090,17 +2637,25 @@ label {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page-tabs {
|
.page-tabs {
|
||||||
grid-template-columns: repeat(auto-fit, minmax(64px, 1fr));
|
grid-auto-flow: column;
|
||||||
|
grid-auto-columns: minmax(5.5rem, max-content);
|
||||||
|
grid-template-columns: none;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 0.1rem;
|
||||||
|
scrollbar-width: thin;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-tab {
|
.page-tab {
|
||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
|
min-width: 5.5rem;
|
||||||
padding: 0.55rem 0.65rem;
|
padding: 0.55rem 0.65rem;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-nav .secondary-button {
|
.side-nav .secondary-button {
|
||||||
|
|||||||
@@ -5,5 +5,11 @@ export default defineConfig({
|
|||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://backend:5000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user