65 Commits

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

After

Width:  |  Height:  |  Size: 242 KiB

+16 -4
View File
@@ -86,6 +86,7 @@ curl -H "Authorization: Bearer <admin-token>" https://your-host/api/metrics
- `FRONTEND_URL`
- `BACKEND_URL`
- `VITE_API_BASE_URL`
- `MAPBOX_ACCESS_TOKEN`
- `REDIS_URL`
- `IMAGE_STORAGE_PROVIDER`
- `S3_ENDPOINT`
@@ -124,8 +125,17 @@ Set these when Wasabi image storage is ready:
- `S3_SECRET_ACCESS_KEY=<secret-key>`
- `S3_PUBLIC_BASE_URL=<optional CDN or public bucket base URL; leave blank for private signed URLs>`
- `S3_KEY_PREFIX=bird-photos`
- `PHOTO_DELIVERY_MODE=proxy`
Use a dedicated private bucket and access key for FlockPal images. Grant only the S3 permissions the app needs for that bucket. When `S3_PUBLIC_BASE_URL` is blank, FlockPal stores private object keys and returns short-lived signed URLs for bird photos.
Use a dedicated private bucket and access key for FlockPal images. Grant only the S3 permissions the app needs for that bucket. When `S3_PUBLIC_BASE_URL` is blank, FlockPal stores private object keys. `PHOTO_DELIVERY_MODE=proxy` streams images through the backend after validating the app photo token; `PHOTO_DELIVERY_MODE=redirect` validates the app token and redirects to a short-lived Wasabi signed URL.
Migrate existing Postgres-stored bird photos after deploying S3 image storage:
```bash
docker compose -f docker-compose.prod.yml exec backend npm run migrate:bird-photos-to-s3 -- --apply
```
Run a dry run first by omitting `--apply`. Use `--limit=10` to migrate a small batch, and `--keep-data-url` if you want to leave the original inline image in Postgres during an initial verification pass.
Bucket settings recommendation:
@@ -194,8 +204,10 @@ Set these when the Stripe Checkout, Customer Portal, and webhook flow is enabled
- `STRIPE_PRICE_HOUSEHOLD_CONURE_YEARLY`
- `STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY` or `STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK`
- `STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY`
- `STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY` or `STRIPE_PRICE_HOUSEHOLD_MACAW`
- `STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY`
- `STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_MONTHLY` or `STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY`
- `STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_YEARLY`
- `STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_MONTHLY` or `STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW`
- `STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_YEARLY`
- `STRIPE_CHECKOUT_SUCCESS_URL`
- `STRIPE_CHECKOUT_CANCEL_URL`
- `STRIPE_PORTAL_RETURN_URL`
@@ -212,7 +224,7 @@ Recommended defaults:
- Enable the proration behavior you want in the Customer Portal configuration. FlockPal treats Stripe as the source of truth for upgrade timing and proration outcomes.
- Point the Stripe webhook endpoint at `https://your-backend-host/api/billing/stripe/webhook`.
- Subscribe the webhook endpoint to at least `checkout.session.completed`, `customer.subscription.created`, `customer.subscription.updated`, and `customer.subscription.deleted`.
- Use one Stripe Price per plan and billing interval. FlockPal maps Stripe price IDs back to `household_basic`, `household_plus`, and `household_macaw`.
- Use one Stripe Price per plan and billing interval. FlockPal maps Stripe price IDs back to `household_basic`, `household_plus`, `household_macaw`, and `household_hyacinth_macaw`.
- After Stripe redirects back to the app, FlockPal now performs a direct billing sync against Stripe and then refreshes the active session. Webhooks are still required so asynchronous subscription changes stay in sync later.
For local development with the Stripe CLI:
Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

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

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

+1117 -19
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -5,5 +5,11 @@ export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://backend:5000',
changeOrigin: true,
},
},
},
});