Compare commits
7
Commits
dev
..
e965cb55ef
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e965cb55ef | ||
|
|
505a9b8496 | ||
|
|
c6dc5b22b8 | ||
|
|
f16e88e2f0 | ||
|
|
016bc187d4 | ||
|
|
104f01f75d | ||
|
|
568aee3e70 |
@@ -14,7 +14,6 @@ PHOTO_DELIVERY_MODE=proxy
|
|||||||
FRONTEND_URL=http://localhost:3000
|
FRONTEND_URL=http://localhost:3000
|
||||||
BACKEND_URL=http://localhost:5000
|
BACKEND_URL=http://localhost:5000
|
||||||
VITE_API_BASE_URL=http://localhost:5000/api
|
VITE_API_BASE_URL=http://localhost:5000/api
|
||||||
MAPBOX_ACCESS_TOKEN=
|
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
TRUST_PROXY=
|
TRUST_PROXY=
|
||||||
ADMIN_EMAILS=corey@blaishome.online
|
ADMIN_EMAILS=corey@blaishome.online
|
||||||
@@ -39,10 +38,3 @@ STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_YEARLY=
|
|||||||
STRIPE_CHECKOUT_SUCCESS_URL=http://localhost:3000/?billing=success
|
STRIPE_CHECKOUT_SUCCESS_URL=http://localhost:3000/?billing=success
|
||||||
STRIPE_CHECKOUT_CANCEL_URL=http://localhost:3000/?billing=cancelled
|
STRIPE_CHECKOUT_CANCEL_URL=http://localhost:3000/?billing=cancelled
|
||||||
STRIPE_PORTAL_RETURN_URL=http://localhost:3000/?billing=portal
|
STRIPE_PORTAL_RETURN_URL=http://localhost:3000/?billing=portal
|
||||||
|
|
||||||
# Daily email for birds without a new weight entry in 48 hours; uses the milestone time zone.
|
|
||||||
WEIGHT_REMINDERS_ENABLED=true
|
|
||||||
|
|
||||||
# Optional absolute HTTPS image URL; defaults to FRONTEND_URL/email-background.png.
|
|
||||||
# Deploy frontend/public/email-background.png before sending the updated email templates.
|
|
||||||
EMAIL_BACKGROUND_URL=
|
|
||||||
|
|||||||
@@ -47,67 +47,6 @@ jobs:
|
|||||||
cd /docker/FlockPal-dev
|
cd /docker/FlockPal-dev
|
||||||
docker compose -f docker-compose.dev.yaml up -d --build
|
docker compose -f docker-compose.dev.yaml up -d --build
|
||||||
|
|
||||||
- name: Notify Discord
|
|
||||||
if: always()
|
|
||||||
env:
|
|
||||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
|
||||||
JOB_STATUS: ${{ job.status }}
|
|
||||||
run: |
|
|
||||||
if [ -z "$DISCORD_WEBHOOK_URL" ]; then
|
|
||||||
echo "DISCORD_WEBHOOK_URL is not configured; skipping Discord notification."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$JOB_STATUS" = "success" ]; then
|
|
||||||
STATUS_TITLE="Deploy succeeded"
|
|
||||||
COLOR=65280
|
|
||||||
else
|
|
||||||
STATUS_TITLE="Deploy failed"
|
|
||||||
COLOR=16711680
|
|
||||||
fi
|
|
||||||
RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
|
||||||
AVATAR_URL="${{ vars.DISCORD_AVATAR_URL }}"
|
|
||||||
AVATAR_URL="${AVATAR_URL:-https://www.flockpal.app/FlockPal-Paint.png}"
|
|
||||||
COMMIT_MESSAGE="$(git -C /docker/FlockPal-dev log -1 --pretty=%s 2>/dev/null || printf '%s' '${{ github.sha }}')"
|
|
||||||
json_escape() {
|
|
||||||
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
|
|
||||||
}
|
|
||||||
BRANCH="$(json_escape '${{ github.ref_name }}')"
|
|
||||||
MESSAGE="$(json_escape "$COMMIT_MESSAGE")"
|
|
||||||
AVATAR_URL="$(json_escape "$AVATAR_URL")"
|
|
||||||
|
|
||||||
cat > /tmp/discord-payload.json <<EOF
|
|
||||||
{
|
|
||||||
"username": "FlockPal Build",
|
|
||||||
"avatar_url": "${AVATAR_URL}",
|
|
||||||
"embeds": [
|
|
||||||
{
|
|
||||||
"title": "${STATUS_TITLE}",
|
|
||||||
"url": "${RUN_URL}",
|
|
||||||
"description": "FlockPal dev deploy",
|
|
||||||
"color": ${COLOR},
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"name": "Branch",
|
|
||||||
"value": "${BRANCH}",
|
|
||||||
"inline": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Message",
|
|
||||||
"value": "${MESSAGE}",
|
|
||||||
"inline": false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
curl --fail --show-error --silent \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d @/tmp/discord-payload.json \
|
|
||||||
"$DISCORD_WEBHOOK_URL" || echo "Discord notification failed."
|
|
||||||
|
|
||||||
deploy-prod:
|
deploy-prod:
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -146,64 +85,3 @@ jobs:
|
|||||||
set -e
|
set -e
|
||||||
cd /docker/FlockPal
|
cd /docker/FlockPal
|
||||||
docker compose -f docker-compose.prod.yml up -d --build
|
docker compose -f docker-compose.prod.yml up -d --build
|
||||||
|
|
||||||
- name: Notify Discord
|
|
||||||
if: always()
|
|
||||||
env:
|
|
||||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
|
||||||
JOB_STATUS: ${{ job.status }}
|
|
||||||
run: |
|
|
||||||
if [ -z "$DISCORD_WEBHOOK_URL" ]; then
|
|
||||||
echo "DISCORD_WEBHOOK_URL is not configured; skipping Discord notification."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$JOB_STATUS" = "success" ]; then
|
|
||||||
STATUS_TITLE="Deploy succeeded"
|
|
||||||
COLOR=65280
|
|
||||||
else
|
|
||||||
STATUS_TITLE="Deploy failed"
|
|
||||||
COLOR=16711680
|
|
||||||
fi
|
|
||||||
RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
|
||||||
AVATAR_URL="${{ vars.DISCORD_AVATAR_URL }}"
|
|
||||||
AVATAR_URL="${AVATAR_URL:-https://www.flockpal.app/FlockPal-Paint.png}"
|
|
||||||
COMMIT_MESSAGE="$(git -C /docker/FlockPal log -1 --pretty=%s 2>/dev/null || printf '%s' '${{ github.sha }}')"
|
|
||||||
json_escape() {
|
|
||||||
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
|
|
||||||
}
|
|
||||||
BRANCH="$(json_escape '${{ github.ref_name }}')"
|
|
||||||
MESSAGE="$(json_escape "$COMMIT_MESSAGE")"
|
|
||||||
AVATAR_URL="$(json_escape "$AVATAR_URL")"
|
|
||||||
|
|
||||||
cat > /tmp/discord-payload.json <<EOF
|
|
||||||
{
|
|
||||||
"username": "FlockPal Build",
|
|
||||||
"avatar_url": "${AVATAR_URL}",
|
|
||||||
"embeds": [
|
|
||||||
{
|
|
||||||
"title": "${STATUS_TITLE}",
|
|
||||||
"url": "${RUN_URL}",
|
|
||||||
"description": "FlockPal production deploy",
|
|
||||||
"color": ${COLOR},
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"name": "Branch",
|
|
||||||
"value": "${BRANCH}",
|
|
||||||
"inline": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Message",
|
|
||||||
"value": "${MESSAGE}",
|
|
||||||
"inline": false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
curl --fail --show-error --silent \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d @/tmp/discord-payload.json \
|
|
||||||
"$DISCORD_WEBHOOK_URL" || echo "Discord notification failed."
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 242 KiB |
@@ -86,7 +86,6 @@ curl -H "Authorization: Bearer <admin-token>" https://your-host/api/metrics
|
|||||||
- `FRONTEND_URL`
|
- `FRONTEND_URL`
|
||||||
- `BACKEND_URL`
|
- `BACKEND_URL`
|
||||||
- `VITE_API_BASE_URL`
|
- `VITE_API_BASE_URL`
|
||||||
- `MAPBOX_ACCESS_TOKEN`
|
|
||||||
- `REDIS_URL`
|
- `REDIS_URL`
|
||||||
- `IMAGE_STORAGE_PROVIDER`
|
- `IMAGE_STORAGE_PROVIDER`
|
||||||
- `S3_ENDPOINT`
|
- `S3_ENDPOINT`
|
||||||
@@ -95,8 +94,6 @@ curl -H "Authorization: Bearer <admin-token>" https://your-host/api/metrics
|
|||||||
- `S3_ACCESS_KEY_ID`
|
- `S3_ACCESS_KEY_ID`
|
||||||
- `S3_SECRET_ACCESS_KEY`
|
- `S3_SECRET_ACCESS_KEY`
|
||||||
- `RESCUE_ONBOARDING_WEBHOOK_URL`
|
- `RESCUE_ONBOARDING_WEBHOOK_URL`
|
||||||
- `DISCORD_WEBHOOK_URL` as a Gitea Actions secret for deploy notifications
|
|
||||||
- `DISCORD_AVATAR_URL` as an optional Gitea Actions variable for the deploy notification icon
|
|
||||||
2. Build and start the production stack:
|
2. Build and start the production stack:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -161,34 +158,6 @@ npm run build
|
|||||||
npm run worker
|
npm run worker
|
||||||
```
|
```
|
||||||
|
|
||||||
### Overdue weight emails
|
|
||||||
|
|
||||||
The worker checks once per calendar day for living birds with no new weight entry in
|
|
||||||
48 hours. Each accepted owner, assistant, or caregiver receives one email per flock
|
|
||||||
listing all birds due for a reminder. The branded email embeds the FlockPal logo
|
|
||||||
and each bird's portrait (including private S3 photos), with the default portrait
|
|
||||||
used when a photo is missing or unavailable. Pending invitees and viewers are excluded.
|
|
||||||
Reminders repeat daily while overdue; adding a new weight restarts the
|
|
||||||
48-hour clock. Birds without weights use their profile creation time.
|
|
||||||
|
|
||||||
Scheduling matches hatch-day reminders: a check 15 seconds after worker startup,
|
|
||||||
then hourly, with one queued job per local date. There is no fixed morning send
|
|
||||||
time. Both use `MILESTONE_REMINDER_TIME_ZONE` (default `America/New_York`).
|
|
||||||
Delivery tracking uses local calendar dates, including daylight-saving changes.
|
|
||||||
Deploying this change removes the old five-minute Redis schedule.
|
|
||||||
|
|
||||||
The clock uses the weight entry's creation timestamp, since the measurement date
|
|
||||||
has no time of day. Editing an existing entry does not restart the clock; entering
|
|
||||||
a historical weight does. Memorialized birds are excluded.
|
|
||||||
|
|
||||||
`WEIGHT_REMINDERS_ENABLED` defaults to `true` in both Compose stacks. Set it to
|
|
||||||
`false` to disable delivery. The worker, Redis, Postgres, and existing SMTP settings
|
|
||||||
must be available. The schema initializer creates the delivery tracking table.
|
|
||||||
Missing SMTP configuration or failed sends leave reminders eligible for retry.
|
|
||||||
Delivery tracking prevents ordinary repeat sends and concurrent claims; as with
|
|
||||||
other SMTP delivery, a crash after acceptance but before saving delivery can cause
|
|
||||||
a duplicate on retry.
|
|
||||||
|
|
||||||
## Auth and flock notes
|
## Auth and flock notes
|
||||||
|
|
||||||
- One user can belong to multiple flocks.
|
- One user can belong to multiple flocks.
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 237 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 234 KiB |
Generated
+1
-1049
File diff suppressed because it is too large
Load Diff
@@ -23,10 +23,7 @@
|
|||||||
"helmet": "8.1.0",
|
"helmet": "8.1.0",
|
||||||
"morgan": "1.10.0",
|
"morgan": "1.10.0",
|
||||||
"nodemailer": "^8.0.5",
|
"nodemailer": "^8.0.5",
|
||||||
"pdfkit": "^0.18.0",
|
|
||||||
"pg": "8.13.1",
|
"pg": "8.13.1",
|
||||||
"qrcode": "^1.5.4",
|
|
||||||
"sharp": "^0.34.5",
|
|
||||||
"stripe": "^22.0.2",
|
"stripe": "^22.0.2",
|
||||||
"zod": "3.24.1"
|
"zod": "3.24.1"
|
||||||
},
|
},
|
||||||
@@ -35,9 +32,7 @@
|
|||||||
"@types/express": "4.17.21",
|
"@types/express": "4.17.21",
|
||||||
"@types/morgan": "1.9.9",
|
"@types/morgan": "1.9.9",
|
||||||
"@types/node": "22.10.2",
|
"@types/node": "22.10.2",
|
||||||
"@types/pdfkit": "^0.17.6",
|
|
||||||
"@types/pg": "8.11.10",
|
"@types/pg": "8.11.10",
|
||||||
"@types/qrcode": "^1.5.6",
|
|
||||||
"tsx": "4.19.2",
|
"tsx": "4.19.2",
|
||||||
"typescript": "5.7.2"
|
"typescript": "5.7.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
// Rebuild the email-safe PNG from the app's SVG tracks and background palette.
|
|
||||||
// Run from backend: node scripts/generate-email-background.mjs
|
|
||||||
import { readFile, writeFile } from 'node:fs/promises';
|
|
||||||
import sharp from 'sharp';
|
|
||||||
const css = await readFile(new URL('../../frontend/src/index.css', import.meta.url), 'utf8');
|
|
||||||
const encoded = css.match(/background-image: url\("data:image\/svg\+xml,([^"\n]+)"\)/)?.[1];
|
|
||||||
if (!encoded) throw new Error('App bird-track background was not found');
|
|
||||||
const tracks = decodeURIComponent(encoded);
|
|
||||||
const gradients = `<defs><linearGradient id="wash" x2="0" y2="1"><stop stop-color="#fef5e7"/><stop offset=".46" stop-color="#e9ddba"/><stop offset="1" stop-color="#d9eadf"/></linearGradient>${[[14,10,22,'#de7c3a',.28],[82,12,20,'#35886e',.26],[24,84,22,'#ddb34e',.2],[86,78,24,'#2b765c',.24],[62,54,16,'#3072a0',.14]].map(([x,y,r,color,opacity],i)=>`<radialGradient id="glow${i}" cx="${x}%" cy="${y}%" r="${r}%"><stop stop-color="${color}" stop-opacity="${opacity}"/><stop offset="1" stop-color="${color}" stop-opacity="0"/></radialGradient>`).join('')}</defs>`;
|
|
||||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="800" height="1100" viewBox="0 0 1600 2200">${gradients}<rect width="1600" height="2200" fill="url(#wash)"/>${[0,1,2,3,4].map(i=>`<rect width="1600" height="2200" fill="url(#glow${i})"/>`).join('')}<g opacity=".42">${tracks.replace(/^<svg[^>]*>/,'').replace(/<\/svg>$/,'')}</g></svg>`;
|
|
||||||
await writeFile(new URL('../assets/email-background.png', import.meta.url), await sharp(Buffer.from(svg)).png().toBuffer());
|
|
||||||
|
|
||||||
await writeFile(new URL('../../frontend/public/email-background.png', import.meta.url), await readFile(new URL('../assets/email-background.png', import.meta.url)));
|
|
||||||
+152
-1386
File diff suppressed because it is too large
Load Diff
+2
-163
@@ -30,9 +30,6 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
ALTER TABLE workspaces
|
ALTER TABLE workspaces
|
||||||
DROP CONSTRAINT IF EXISTS workspaces_id_check;
|
DROP CONSTRAINT IF EXISTS workspaces_id_check;
|
||||||
|
|
||||||
ALTER TABLE users
|
|
||||||
ADD COLUMN IF NOT EXISTS education_opt_out BOOLEAN NOT NULL DEFAULT FALSE;
|
|
||||||
|
|
||||||
ALTER TABLE workspaces
|
ALTER TABLE workspaces
|
||||||
ADD COLUMN IF NOT EXISTS billing_email VARCHAR(255),
|
ADD COLUMN IF NOT EXISTS billing_email VARCHAR(255),
|
||||||
ADD COLUMN IF NOT EXISTS billing_plan VARCHAR(32) NOT NULL DEFAULT 'household_basic',
|
ADD COLUMN IF NOT EXISTS billing_plan VARCHAR(32) NOT NULL DEFAULT 'household_basic',
|
||||||
@@ -142,37 +139,6 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_auth_sessions_created_user
|
CREATE INDEX IF NOT EXISTS idx_auth_sessions_created_user
|
||||||
ON auth_sessions (created_at DESC, user_id);
|
ON auth_sessions (created_at DESC, user_id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS daily_education (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
publish_date DATE NOT NULL UNIQUE,
|
|
||||||
fact TEXT NOT NULL,
|
|
||||||
quiz_questions JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
||||||
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER TABLE daily_education
|
|
||||||
ALTER COLUMN quiz_questions SET DEFAULT '[]'::jsonb;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_daily_education_publish_date
|
|
||||||
ON daily_education (publish_date DESC);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS education_question_bank (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
prompt VARCHAR(500) NOT NULL,
|
|
||||||
options JSONB NOT NULL,
|
|
||||||
correct_answer_index INTEGER NOT NULL,
|
|
||||||
explanation VARCHAR(800),
|
|
||||||
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
CHECK (correct_answer_index >= 0 AND correct_answer_index <= 3)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_education_question_bank_created
|
|
||||||
ON education_question_bank (created_at DESC);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS integration_tokens (
|
CREATE TABLE IF NOT EXISTS integration_tokens (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
@@ -249,12 +215,6 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
motivators VARCHAR(1000),
|
motivators VARCHAR(1000),
|
||||||
demotivators VARCHAR(1000),
|
demotivators VARCHAR(1000),
|
||||||
favorite_snack VARCHAR(160),
|
favorite_snack VARCHAR(160),
|
||||||
location_label VARCHAR(160),
|
|
||||||
location_details JSONB,
|
|
||||||
vet_clinic_name VARCHAR(160),
|
|
||||||
vet_clinic_address VARCHAR(500),
|
|
||||||
vet_account_number VARCHAR(120),
|
|
||||||
vet_doctor_name VARCHAR(160),
|
|
||||||
gender VARCHAR(16) NOT NULL DEFAULT 'unknown',
|
gender VARCHAR(16) NOT NULL DEFAULT 'unknown',
|
||||||
date_of_birth DATE,
|
date_of_birth DATE,
|
||||||
gotcha_day DATE,
|
gotcha_day DATE,
|
||||||
@@ -279,12 +239,6 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
ADD COLUMN IF NOT EXISTS motivators VARCHAR(1000),
|
ADD COLUMN IF NOT EXISTS motivators VARCHAR(1000),
|
||||||
ADD COLUMN IF NOT EXISTS demotivators VARCHAR(1000),
|
ADD COLUMN IF NOT EXISTS demotivators VARCHAR(1000),
|
||||||
ADD COLUMN IF NOT EXISTS favorite_snack VARCHAR(160),
|
ADD COLUMN IF NOT EXISTS favorite_snack VARCHAR(160),
|
||||||
ADD COLUMN IF NOT EXISTS location_label VARCHAR(160),
|
|
||||||
ADD COLUMN IF NOT EXISTS location_details JSONB,
|
|
||||||
ADD COLUMN IF NOT EXISTS vet_clinic_name VARCHAR(160),
|
|
||||||
ADD COLUMN IF NOT EXISTS vet_clinic_address VARCHAR(500),
|
|
||||||
ADD COLUMN IF NOT EXISTS vet_account_number VARCHAR(120),
|
|
||||||
ADD COLUMN IF NOT EXISTS vet_doctor_name VARCHAR(160),
|
|
||||||
ADD COLUMN IF NOT EXISTS gender VARCHAR(16) NOT NULL DEFAULT 'unknown',
|
ADD COLUMN IF NOT EXISTS gender VARCHAR(16) NOT NULL DEFAULT 'unknown',
|
||||||
ADD COLUMN IF NOT EXISTS date_of_birth DATE,
|
ADD COLUMN IF NOT EXISTS date_of_birth DATE,
|
||||||
ADD COLUMN IF NOT EXISTS gotcha_day DATE,
|
ADD COLUMN IF NOT EXISTS gotcha_day DATE,
|
||||||
@@ -330,8 +284,8 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
|
|
||||||
DROP INDEX IF EXISTS idx_birds_workspace_tag_id;
|
DROP INDEX IF EXISTS idx_birds_workspace_tag_id;
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_birds_global_tag_id
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_birds_workspace_tag_id
|
||||||
ON birds (LOWER(BTRIM(tag_id)))
|
ON birds (workspace_id, LOWER(tag_id))
|
||||||
WHERE tag_id IS NOT NULL
|
WHERE tag_id IS NOT NULL
|
||||||
AND BTRIM(tag_id) <> ''
|
AND BTRIM(tag_id) <> ''
|
||||||
AND LOWER(BTRIM(tag_id)) NOT IN ('unknown', 'not recorded', 'n/a', 'na', 'none');
|
AND LOWER(BTRIM(tag_id)) NOT IN ('unknown', 'not recorded', 'n/a', 'na', 'none');
|
||||||
@@ -384,92 +338,6 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
ON pending_bird_transfers (bird_id)
|
ON pending_bird_transfers (bird_id)
|
||||||
WHERE completed_at IS NULL;
|
WHERE completed_at IS NULL;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS bird_transfer_codes (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
code VARCHAR(32) NOT NULL UNIQUE,
|
|
||||||
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
|
||||||
source_workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
|
||||||
requested_by_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
completed_at TIMESTAMPTZ,
|
|
||||||
completed_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL,
|
|
||||||
revoked_at TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_bird_transfer_codes_open_bird
|
|
||||||
ON bird_transfer_codes (bird_id, created_at DESC)
|
|
||||||
WHERE completed_at IS NULL
|
|
||||||
AND revoked_at IS NULL;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_bird_transfer_codes_code_open
|
|
||||||
ON bird_transfer_codes (code)
|
|
||||||
WHERE completed_at IS NULL
|
|
||||||
AND revoked_at IS NULL;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS bird_timeline_events (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
|
||||||
event_type VARCHAR(40) NOT NULL,
|
|
||||||
from_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL,
|
|
||||||
to_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL,
|
|
||||||
from_workspace_name VARCHAR(160),
|
|
||||||
to_workspace_name VARCHAR(160),
|
|
||||||
from_owner_email VARCHAR(255),
|
|
||||||
to_owner_email VARCHAR(255),
|
|
||||||
location_label VARCHAR(160),
|
|
||||||
location_details JSONB,
|
|
||||||
note TEXT,
|
|
||||||
event_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
|
||||||
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER TABLE bird_timeline_events
|
|
||||||
ADD COLUMN IF NOT EXISTS note TEXT,
|
|
||||||
ADD COLUMN IF NOT EXISTS location_details JSONB,
|
|
||||||
ADD COLUMN IF NOT EXISTS event_date DATE NOT NULL DEFAULT CURRENT_DATE;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_bird_timeline_events_bird_created
|
|
||||||
ON bird_timeline_events (bird_id, event_date DESC, created_at DESC);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS flock_notes (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
|
||||||
bird_id UUID REFERENCES birds(id) ON DELETE SET NULL,
|
|
||||||
title VARCHAR(160) NOT NULL,
|
|
||||||
body TEXT NOT NULL,
|
|
||||||
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_flock_notes_workspace_updated
|
|
||||||
ON flock_notes (workspace_id, updated_at DESC);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_flock_notes_bird_updated
|
|
||||||
ON flock_notes (bird_id, updated_at DESC)
|
|
||||||
WHERE bird_id IS NOT NULL;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS audit_log_entries (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
|
||||||
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
actor_name VARCHAR(160),
|
|
||||||
actor_email VARCHAR(255),
|
|
||||||
action VARCHAR(80) NOT NULL,
|
|
||||||
entity_type VARCHAR(80) NOT NULL,
|
|
||||||
entity_id VARCHAR(120),
|
|
||||||
entity_name VARCHAR(255),
|
|
||||||
details JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_audit_log_entries_workspace_created
|
|
||||||
ON audit_log_entries (workspace_id, created_at DESC);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_audit_log_entries_entity
|
|
||||||
ON audit_log_entries (workspace_id, entity_type, entity_id, created_at DESC);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS weight_records (
|
CREATE TABLE IF NOT EXISTS weight_records (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
||||||
@@ -480,17 +348,6 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
UNIQUE (bird_id, recorded_on)
|
UNIQUE (bird_id, recorded_on)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS weight_reminder_deliveries (
|
|
||||||
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
|
||||||
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
|
||||||
activity_at TIMESTAMPTZ NOT NULL,
|
|
||||||
recipient TEXT NOT NULL,
|
|
||||||
claim_token UUID NOT NULL,
|
|
||||||
claimed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
delivered_at TIMESTAMPTZ,
|
|
||||||
PRIMARY KEY (bird_id, workspace_id, activity_at, recipient)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS vet_visits (
|
CREATE TABLE IF NOT EXISTS vet_visits (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
||||||
@@ -512,7 +369,6 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
start_date DATE NOT NULL,
|
start_date DATE NOT NULL,
|
||||||
end_date DATE,
|
end_date DATE,
|
||||||
notes VARCHAR(1000),
|
notes VARCHAR(1000),
|
||||||
reminders_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
CHECK (end_date IS NULL OR end_date >= start_date)
|
CHECK (end_date IS NULL OR end_date >= start_date)
|
||||||
);
|
);
|
||||||
@@ -520,9 +376,6 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
ALTER TABLE medications
|
ALTER TABLE medications
|
||||||
ADD COLUMN IF NOT EXISTS dose_schedule JSONB NOT NULL DEFAULT '[{"key":"dose-1","label":"Dose","time":""}]'::jsonb;
|
ADD COLUMN IF NOT EXISTS dose_schedule JSONB NOT NULL DEFAULT '[{"key":"dose-1","label":"Dose","time":""}]'::jsonb;
|
||||||
|
|
||||||
ALTER TABLE medications
|
|
||||||
ADD COLUMN IF NOT EXISTS reminders_enabled BOOLEAN NOT NULL DEFAULT FALSE;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS bird_milestone_reminder_deliveries (
|
CREATE TABLE IF NOT EXISTS bird_milestone_reminder_deliveries (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
||||||
@@ -556,17 +409,6 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
ALTER TABLE medication_administrations
|
ALTER TABLE medication_administrations
|
||||||
ADD COLUMN IF NOT EXISTS administration_slot VARCHAR(80) NOT NULL DEFAULT 'dose-1';
|
ADD COLUMN IF NOT EXISTS administration_slot VARCHAR(80) NOT NULL DEFAULT 'dose-1';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS medication_reminder_deliveries (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
medication_id UUID NOT NULL REFERENCES medications(id) ON DELETE CASCADE,
|
|
||||||
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
|
||||||
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
|
||||||
scheduled_on DATE NOT NULL,
|
|
||||||
administration_slot VARCHAR(80) NOT NULL,
|
|
||||||
delivered_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE (medication_id, scheduled_on, administration_slot)
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER TABLE medication_administrations
|
ALTER TABLE medication_administrations
|
||||||
DROP CONSTRAINT IF EXISTS medication_administrations_medication_id_administered_on_key;
|
DROP CONSTRAINT IF EXISTS medication_administrations_medication_id_administered_on_key;
|
||||||
|
|
||||||
@@ -585,9 +427,6 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_bird_milestone_reminder_deliveries_workspace
|
CREATE INDEX IF NOT EXISTS idx_bird_milestone_reminder_deliveries_workspace
|
||||||
ON bird_milestone_reminder_deliveries (workspace_id, delivered_on DESC);
|
ON bird_milestone_reminder_deliveries (workspace_id, delivered_on DESC);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_medication_reminder_deliveries_workspace
|
|
||||||
ON medication_reminder_deliveries (workspace_id, scheduled_on DESC);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_medication_administrations_bird_administered_on
|
CREATE INDEX IF NOT EXISTS idx_medication_administrations_bird_administered_on
|
||||||
ON medication_administrations (bird_id, administered_on DESC);
|
ON medication_administrations (bird_id, administered_on DESC);
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
import { readFile } from 'node:fs/promises';
|
|
||||||
import sharp from 'sharp';
|
|
||||||
import type { WeightReminder } from '../reminders/weightReminders.js';
|
|
||||||
import { getS3ImageStorageConfig } from '../storage/imageStorageConfig.js';
|
|
||||||
import { getSignedS3ObjectUrl } from '../storage/s3Client.js';
|
|
||||||
|
|
||||||
const asset = (name: string) => new URL(`../../assets/${name}`, import.meta.url);
|
|
||||||
const thumbnail = (content: Buffer) => sharp(content).rotate().resize(192, 192, { fit: 'cover' }).jpeg({ quality: 80 }).toBuffer();
|
|
||||||
|
|
||||||
export const loadWeightReminderPortrait = async (bird: Pick<WeightReminder, 'bird_id' | 'photo_object_key' | 'photo_data_url'>): Promise<Buffer> => {
|
|
||||||
try {
|
|
||||||
if (bird.photo_object_key) {
|
|
||||||
const config = getS3ImageStorageConfig();
|
|
||||||
if (config) {
|
|
||||||
const response = await fetch(getSignedS3ObjectUrl({ config, objectKey: bird.photo_object_key, expiresInSeconds: 300 }), {
|
|
||||||
signal: AbortSignal.timeout(10_000),
|
|
||||||
});
|
|
||||||
if (!response.ok) throw new Error(`Photo storage returned ${response.status}`);
|
|
||||||
return await thumbnail(Buffer.from(await response.arrayBuffer()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const match = bird.photo_data_url?.match(/^data:image\/(?:png|jpe?g|webp|gif);base64,(.+)$/);
|
|
||||||
if (match) return await thumbnail(Buffer.from(match[1], 'base64'));
|
|
||||||
} catch {
|
|
||||||
console.warn(`Unable to load weight reminder portrait for bird ${bird.bird_id}; using default portrait.`);
|
|
||||||
}
|
|
||||||
return thumbnail(await readFile(asset('yoda-default.png')));
|
|
||||||
};
|
|
||||||
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import { test } from 'node:test';
|
|
||||||
import { buildEmailLayout } from './emailLayout.js';
|
|
||||||
|
|
||||||
test('shared layout escapes copy and embeds the app background and bird portrait', async () => {
|
|
||||||
const mail = await buildEmailLayout({
|
|
||||||
backgroundUrl: 'https://flockpal.test/email-background.png',
|
|
||||||
eyebrow: 'Medication <reminder>', headline: 'Medication time for Kiwi & Peep',
|
|
||||||
contentHtml: '<p>Morning at 8:00 AM</p>',
|
|
||||||
action: { url: 'https://flockpal.test/?a=1&b=2', label: 'Open <FlockPal>' },
|
|
||||||
bird: { id: 'bird-1', name: 'Kiwi & Peep', photo_data_url: null, photo_object_key: null },
|
|
||||||
});
|
|
||||||
const html = String(mail.html);
|
|
||||||
assert.match(html, /Medication <reminder>/);
|
|
||||||
assert.match(html, /Medication time for Kiwi & Peep/);
|
|
||||||
assert.match(html, /a=1&b=2/);
|
|
||||||
assert.match(html, /Open <FlockPal>/);
|
|
||||||
assert.match(html, /Morning at 8:00 AM/);
|
|
||||||
assert.match(html, /background="https:\/\/flockpal.test\/email-background.png"/);
|
|
||||||
assert.match(html, /background-image:url\('https:\/\/flockpal.test\/email-background.png'\)/);
|
|
||||||
assert.doesNotMatch(html, /flockpal-pattern|cid:flockpal-background/);
|
|
||||||
assert.doesNotMatch(html, /48-hour|weights are overdue|data:image/);
|
|
||||||
for (const attachment of mail.attachments ?? []) {
|
|
||||||
assert.ok(html.includes(`cid:${attachment.cid}`));
|
|
||||||
assert.equal(attachment.contentDisposition, 'inline');
|
|
||||||
assert.ok(Buffer.isBuffer(attachment.content));
|
|
||||||
}
|
|
||||||
assert.equal((html.match(/<table\b/g) ?? []).length, (html.match(/<\/table>/g) ?? []).length);
|
|
||||||
assert.equal((html.match(/<td\b/g) ?? []).length, (html.match(/<\/td>/g) ?? []).length);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('transactional layout omits optional portrait, action and reminder footer', async () => {
|
|
||||||
const mail = await buildEmailLayout({ eyebrow: 'Rescue status', headline: 'Flock update', contentHtml: '<p>Status changed</p>' });
|
|
||||||
assert.equal(mail.attachments?.length, 1);
|
|
||||||
assert.doesNotMatch(String(mail.html), /bird-portrait|<a |48-hour/);
|
|
||||||
});
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { readFile } from 'node:fs/promises';
|
|
||||||
import type { SendMailOptions } from 'nodemailer';
|
|
||||||
import sharp from 'sharp';
|
|
||||||
import { loadWeightReminderPortrait } from './birdPortrait.js';
|
|
||||||
|
|
||||||
export const escapeHtml = (value: string) => value.replace(/[&<>"']/g, char => ({
|
|
||||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
||||||
})[char]!);
|
|
||||||
const asset = (name: string) => new URL(`../../assets/${name}`, import.meta.url);
|
|
||||||
|
|
||||||
export const buildEmailLayout = async ({ eyebrow, headline, preheader = headline, contentHtml, action, footer, bird, backgroundUrl = process.env.EMAIL_BACKGROUND_URL || new URL('/email-background.png', process.env.FRONTEND_URL || 'http://localhost:3000').href }: {
|
|
||||||
eyebrow: string;
|
|
||||||
headline: string;
|
|
||||||
preheader?: string;
|
|
||||||
/** Trusted markup; escape all dynamic values before passing them. */
|
|
||||||
contentHtml: string;
|
|
||||||
action?: { url: string; label: string };
|
|
||||||
footer?: string;
|
|
||||||
backgroundUrl?: string;
|
|
||||||
bird?: { id: string; name: string; photo_data_url: string | null; photo_object_key: string | null };
|
|
||||||
}): Promise<Pick<SendMailOptions, 'html' | 'attachments'>> => ({
|
|
||||||
attachments: [
|
|
||||||
{ filename: 'flockpal-logo.png', cid: 'flockpal-logo', contentType: 'image/png', contentDisposition: 'inline', content: await sharp(await readFile(asset('flockpal-logo.png'))).resize({ width: 480 }).png().toBuffer() },
|
|
||||||
...(bird ? [{ filename: 'bird-portrait.jpg', cid: 'bird-portrait', contentType: 'image/jpeg', contentDisposition: 'inline' as const, content: await loadWeightReminderPortrait({ bird_id: bird.id, photo_data_url: bird.photo_data_url, photo_object_key: bird.photo_object_key }) }] : []),
|
|
||||||
],
|
|
||||||
html: `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head>
|
|
||||||
<body style="margin:0;padding:0;background-color:#fef5e7;">
|
|
||||||
<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;">${escapeHtml(preheader)}</div>
|
|
||||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="width:100%;background-color:#fef5e7;font-family:Arial,sans-serif;color:#1f2a2a;line-height:1.5;">
|
|
||||||
<tr><td align="center" background="${escapeHtml(backgroundUrl)}" style="padding:24px 12px;background-color:#fef5e7;background-image:url('${escapeHtml(backgroundUrl)}');background-position:center top;background-repeat:repeat;background-size:100% auto;">
|
|
||||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:600px;background-color:#edf8ef;border:1px solid #d3e5d8;border-radius:24px;">
|
|
||||||
<tr><td align="center" style="padding:24px;border-bottom:1px solid #d3e5d8;">
|
|
||||||
<img src="cid:flockpal-logo" width="220" alt="FlockPal" style="display:block;width:220px;max-width:100%;height:auto;border:0;" />
|
|
||||||
</td></tr>
|
|
||||||
<tr><td style="padding:24px;">
|
|
||||||
<p style="margin:0 0 8px;font-size:12px;font-weight:bold;letter-spacing:1.5px;color:#238a5a;text-transform:uppercase;">${escapeHtml(eyebrow)}</p>
|
|
||||||
<h1 style="margin:0 0 12px;font-size:26px;line-height:1.2;word-break:break-word;">${escapeHtml(headline)}</h1>
|
|
||||||
${bird ? `<img src="cid:bird-portrait" width="76" height="76" alt="${escapeHtml(bird.name)}" style="display:block;border:0;border-radius:16px;margin:0 0 16px;" />` : ''}
|
|
||||||
${contentHtml}
|
|
||||||
${action ? `<table role="presentation" cellspacing="0" cellpadding="0" style="margin-top:24px;"><tr><td bgcolor="#238a5a" style="border-radius:24px;text-align:center;"><a href="${escapeHtml(action.url)}" style="display:inline-block;padding:13px 22px;border:1px solid #238a5a;border-radius:24px;color:#ffffff;font-size:16px;font-weight:bold;text-decoration:none;">${escapeHtml(action.label)}</a></td></tr></table>` : ''}
|
|
||||||
${footer ? `<p style="margin:20px 0 0;font-size:13px;color:#52645b;">${escapeHtml(footer)}</p>` : ''}
|
|
||||||
</td></tr>
|
|
||||||
</table>
|
|
||||||
<p style="margin:16px 0 0;color:#52645b;font-size:12px;">FlockPal · A little care, every day.</p>
|
|
||||||
</td></tr>
|
|
||||||
</table>
|
|
||||||
</body></html>`,
|
|
||||||
});
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import { readFile } from 'node:fs/promises';
|
|
||||||
import { test } from 'node:test';
|
|
||||||
import sharp from 'sharp';
|
|
||||||
import { buildWeightReminderEmail, loadWeightReminderPortrait } from './weightReminderEmail.js';
|
|
||||||
import type { WeightReminder } from '../reminders/weightReminders.js';
|
|
||||||
|
|
||||||
const bird: WeightReminder = {
|
|
||||||
bird_id: 'bird-1', workspace_id: 1, bird_name: 'Kiwi & <Peep>', species: 'Cockatiel',
|
|
||||||
workspace_name: 'Our <Flock>', activity_at: '2026-09-01T12:00:00Z', recipient: 'owner@example.test',
|
|
||||||
photo_data_url: null, photo_object_key: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
test('branded grouped email embeds distinct portraits and escapes dynamic content', async () => {
|
|
||||||
const mail = await buildWeightReminderEmail([bird, { ...bird, bird_id: 'bird-2', bird_name: 'Yoda' }], 'https://flockpal.test/?a=1&b=2');
|
|
||||||
assert.equal(mail.attachments?.length, 3);
|
|
||||||
assert.deepEqual(mail.attachments?.map(a => a.cid), ['flockpal-logo', 'weight-bird-0', 'weight-bird-1']);
|
|
||||||
for (const attachment of mail.attachments ?? []) {
|
|
||||||
assert.equal(attachment.contentDisposition, 'inline');
|
|
||||||
assert.ok(Buffer.isBuffer(attachment.content));
|
|
||||||
assert.ok(String(mail.html).includes(`cid:${attachment.cid}`));
|
|
||||||
}
|
|
||||||
assert.match(String(mail.html), /Kiwi & <Peep>/);
|
|
||||||
assert.match(String(mail.html), /Our <Flock>/);
|
|
||||||
assert.match(String(mail.html), /a=1&b=2/);
|
|
||||||
assert.match(String(mail.text), /Kiwi & <Peep>/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('database portrait is resized and invalid photos fall back to the default', async () => {
|
|
||||||
const source = await readFile(new URL('../../assets/yoda.png', import.meta.url));
|
|
||||||
const photo = await loadWeightReminderPortrait({ ...bird, photo_data_url: `data:image/png;base64,${source.toString('base64')}` });
|
|
||||||
const metadata = await sharp(photo).metadata();
|
|
||||||
assert.equal(metadata.width, 192);
|
|
||||||
assert.equal(metadata.height, 192);
|
|
||||||
assert.equal(metadata.format, 'jpeg');
|
|
||||||
assert.deepEqual(await loadWeightReminderPortrait({ ...bird, photo_data_url: 'data:image/png;base64,broken' }), await loadWeightReminderPortrait(bird));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('private S3 portrait is downloaded and embedded rather than linked', async (t) => {
|
|
||||||
const env = { IMAGE_STORAGE_PROVIDER: 's3', S3_ENDPOINT: 'https://storage.example.test', S3_REGION: 'us-east-1', S3_BUCKET: 'portraits', S3_ACCESS_KEY_ID: 'test', S3_SECRET_ACCESS_KEY: 'test' };
|
|
||||||
const previous = Object.fromEntries(Object.keys(env).map(key => [key, process.env[key]]));
|
|
||||||
Object.assign(process.env, env);
|
|
||||||
try {
|
|
||||||
const source = await readFile(new URL('../../assets/yoda.png', import.meta.url));
|
|
||||||
t.mock.method(globalThis, 'fetch', async (url: string) => {
|
|
||||||
assert.match(String(url), /private-bird.png/);
|
|
||||||
return new Response(source);
|
|
||||||
});
|
|
||||||
const photo = await loadWeightReminderPortrait({ ...bird, photo_object_key: 'private-bird.png' });
|
|
||||||
assert.equal((await sharp(photo).metadata()).width, 192);
|
|
||||||
t.mock.restoreAll();
|
|
||||||
t.mock.method(globalThis, 'fetch', async () => new Response('', { status: 404 }));
|
|
||||||
assert.deepEqual(await loadWeightReminderPortrait({ ...bird, photo_object_key: 'missing.png' }), await loadWeightReminderPortrait(bird));
|
|
||||||
} finally {
|
|
||||||
for (const [key, value] of Object.entries(previous)) {
|
|
||||||
if (value === undefined) delete process.env[key];
|
|
||||||
else process.env[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { buildEmailLayout, escapeHtml } from './emailLayout.js';
|
|
||||||
import type { SendMailOptions } from 'nodemailer';
|
|
||||||
import type { WeightReminder } from '../reminders/weightReminders.js';
|
|
||||||
import { loadWeightReminderPortrait } from './birdPortrait.js';
|
|
||||||
export { loadWeightReminderPortrait } from './birdPortrait.js';
|
|
||||||
|
|
||||||
export const buildWeightReminderEmail = async (
|
|
||||||
reminders: WeightReminder[],
|
|
||||||
frontendUrl: string,
|
|
||||||
loadPortrait = loadWeightReminderPortrait,
|
|
||||||
): Promise<Pick<SendMailOptions, 'subject' | 'text' | 'html' | 'attachments'>> => {
|
|
||||||
if (!reminders.length) throw new Error('A weight reminder email requires at least one bird');
|
|
||||||
const flock = reminders[0].workspace_name;
|
|
||||||
const attachments: NonNullable<SendMailOptions['attachments']> = [];
|
|
||||||
const rows: string[] = [];
|
|
||||||
for (const [index, bird] of reminders.entries()) {
|
|
||||||
const cid = `weight-bird-${index}`;
|
|
||||||
attachments.push({ filename: `${cid}.jpg`, cid, content: await loadPortrait(bird), contentType: 'image/jpeg', contentDisposition: 'inline' });
|
|
||||||
rows.push(`<tr>
|
|
||||||
<td width="88" style="padding:16px 12px 16px 0;border-bottom:1px solid #d3e5d8;vertical-align:middle;">
|
|
||||||
<img src="cid:${cid}" width="76" height="76" alt="${escapeHtml(bird.bird_name)}" style="display:block;border:0;border-radius:16px;" />
|
|
||||||
</td>
|
|
||||||
<td style="padding:16px 0;border-bottom:1px solid #d3e5d8;vertical-align:middle;word-break:break-word;">
|
|
||||||
<p style="margin:0 0 4px;font-size:18px;font-weight:bold;color:#1f2a2a;">${escapeHtml(bird.bird_name)}</p>
|
|
||||||
<p style="margin:0;font-size:14px;color:#52645b;">${escapeHtml(bird.species)}</p>
|
|
||||||
<p style="margin:6px 0 0;font-size:13px;color:#63562d;">Ready for a weight check</p>
|
|
||||||
</td>
|
|
||||||
</tr>`);
|
|
||||||
}
|
|
||||||
const layout = await buildEmailLayout({
|
|
||||||
eyebrow: 'Daily weight reminder',
|
|
||||||
headline: 'A little check-in for your flock',
|
|
||||||
preheader: `A little check-in for ${flock}: it's time to log your birds' weights.`,
|
|
||||||
contentHtml: `<p style="margin:0 0 16px;font-size:16px;">These birds in <strong>${escapeHtml(flock)}</strong> haven't had a new weight entry in at least 48 hours.</p><table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse:collapse;table-layout:fixed;">${rows.join('')}</table>`,
|
|
||||||
action: { url: frontendUrl, label: 'Record their weights' },
|
|
||||||
footer: "You'll receive a daily reminder while weights are overdue. A new weight entry restarts the 48-hour clock.",
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
subject: `Weight reminders for ${flock}`,
|
|
||||||
text: `Daily weight reminder — ${flock}\n\nThese birds haven't had a new weight entry in at least 48 hours:\n\n${reminders.map(bird => `- ${bird.bird_name} (${bird.species})`).join('\n')}\n\nOpen FlockPal to record their weights: ${frontendUrl}\n\nYou'll receive a daily reminder while weights are overdue.`,
|
|
||||||
...layout,
|
|
||||||
attachments: [...(layout.attachments ?? []), ...attachments],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
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();
|
|
||||||
};
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
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');
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { Queue } from 'bullmq';
|
|
||||||
import { getReminderDate } from '../reminders/reminderDate.js';
|
|
||||||
import { redisConnection } from './redisConnection.js';
|
|
||||||
|
|
||||||
export const weightReminderQueueName = 'weight-reminders';
|
|
||||||
export const weightReminderQueue = new Queue(weightReminderQueueName, {
|
|
||||||
connection: redisConnection,
|
|
||||||
defaultJobOptions: {
|
|
||||||
attempts: 3,
|
|
||||||
backoff: { type: 'exponential', delay: 60_000 },
|
|
||||||
removeOnComplete: 100,
|
|
||||||
removeOnFail: 1000,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const enqueueDailyWeightReminder = (now = new Date()) => {
|
|
||||||
const runDate = getReminderDate(now);
|
|
||||||
return weightReminderQueue.add('check-overdue-weights', { runDate }, {
|
|
||||||
jobId: `weight-reminders-${runDate}`,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const startWeightReminderScheduler = async () => {
|
|
||||||
// Remove the previous five-minute schedule during upgrades and when disabled.
|
|
||||||
await weightReminderQueue.removeJobScheduler('weight-reminders');
|
|
||||||
if (process.env.WEIGHT_REMINDERS_ENABLED === 'false') return () => {};
|
|
||||||
await weightReminderQueue.setGlobalConcurrency(1);
|
|
||||||
let lastRunDate = '';
|
|
||||||
const runIfNeeded = async () => {
|
|
||||||
const now = new Date();
|
|
||||||
const runDate = getReminderDate(now);
|
|
||||||
if (lastRunDate === runDate) return;
|
|
||||||
await enqueueDailyWeightReminder(now);
|
|
||||||
lastRunDate = runDate;
|
|
||||||
};
|
|
||||||
const check = () => {
|
|
||||||
void runIfNeeded().catch(error => console.error('Weight reminder scheduler failed', error));
|
|
||||||
};
|
|
||||||
const startup = setTimeout(check, 15_000);
|
|
||||||
const interval = setInterval(check, 60 * 60_000);
|
|
||||||
return () => { clearTimeout(startup); clearInterval(interval); };
|
|
||||||
};
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import { test } from 'node:test';
|
|
||||||
import { getReminderDate } from './reminderDate.js';
|
|
||||||
|
|
||||||
test('daily reminder dates use the configured zone across midnight and DST', () => {
|
|
||||||
const previous = process.env.MILESTONE_REMINDER_TIME_ZONE;
|
|
||||||
try {
|
|
||||||
process.env.MILESTONE_REMINDER_TIME_ZONE = 'America/New_York';
|
|
||||||
assert.equal(getReminderDate(new Date('2026-09-06T03:59:59Z')), '2026-09-05');
|
|
||||||
assert.equal(getReminderDate(new Date('2026-09-06T04:00:00Z')), '2026-09-06');
|
|
||||||
assert.equal(getReminderDate(new Date('2026-03-08T06:59:59Z')), '2026-03-08');
|
|
||||||
assert.equal(getReminderDate(new Date('2026-03-08T07:00:00Z')), '2026-03-08');
|
|
||||||
assert.equal(getReminderDate(new Date('2026-11-01T05:30:00Z')), '2026-11-01');
|
|
||||||
assert.equal(getReminderDate(new Date('2026-11-01T06:30:00Z')), '2026-11-01');
|
|
||||||
process.env.MILESTONE_REMINDER_TIME_ZONE = 'UTC';
|
|
||||||
assert.equal(getReminderDate(new Date('2026-09-06T03:59:59Z')), '2026-09-06');
|
|
||||||
} finally {
|
|
||||||
if (previous === undefined) delete process.env.MILESTONE_REMINDER_TIME_ZONE;
|
|
||||||
else process.env.MILESTONE_REMINDER_TIME_ZONE = previous;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export const getReminderTimeZone = () => process.env.MILESTONE_REMINDER_TIME_ZONE?.trim() || 'America/New_York';
|
|
||||||
|
|
||||||
export const getReminderDate = (now = new Date()) => new Intl.DateTimeFormat('en-CA', {
|
|
||||||
timeZone: getReminderTimeZone(), year: 'numeric', month: '2-digit', day: '2-digit',
|
|
||||||
}).format(now);
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import { test } from 'node:test';
|
|
||||||
import { runWeightReminders, type WeightReminder } from './weightReminders.js';
|
|
||||||
|
|
||||||
const bird = (id: string, workspace = 1, recipient = 'owner@example.com'): WeightReminder => ({
|
|
||||||
species: 'Cockatiel', photo_data_url: null, photo_object_key: null,
|
|
||||||
bird_id: id, workspace_id: workspace, recipient,
|
|
||||||
bird_name: id, workspace_name: `Flock ${workspace}`, activity_at: '2026-09-01T12:00:00Z',
|
|
||||||
});
|
|
||||||
|
|
||||||
test('groups overdue birds into one email per flock and recipient', async () => {
|
|
||||||
const sent: WeightReminder[][] = [];
|
|
||||||
const finished: boolean[] = [];
|
|
||||||
const result = await runWeightReminders(async (birds) => { sent.push(birds); return true; }, {
|
|
||||||
list: async () => [bird('a'), bird('b'), bird('c', 2), bird('a', 1, 'caregiver@example.com')],
|
|
||||||
claim: async () => true,
|
|
||||||
finish: async (_token, delivered) => { finished.push(delivered); },
|
|
||||||
});
|
|
||||||
assert.deepEqual(sent.map((group) => group.map((item) => item.bird_id)), [['a', 'b'], ['c'], ['a']]);
|
|
||||||
assert.deepEqual(finished, [true, true, true, true]);
|
|
||||||
assert.equal(result.sent, 3);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('omits birds whose claim was lost or whose weight changed', async () => {
|
|
||||||
const sent: WeightReminder[][] = [];
|
|
||||||
await runWeightReminders(async (birds) => { sent.push(birds); return true; }, {
|
|
||||||
list: async () => [bird('a'), bird('b')],
|
|
||||||
claim: async (reminder) => reminder.bird_id === 'b', finish: async () => {},
|
|
||||||
});
|
|
||||||
assert.deepEqual(sent.map((group) => group.map((item) => item.bird_id)), [['b']]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('does not email when all claims are unavailable', async () => {
|
|
||||||
await runWeightReminders(async () => { assert.fail('Unexpected email'); }, {
|
|
||||||
list: async () => [bird('a')], claim: async () => false, finish: async () => {},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('missing SMTP releases all claims without recording delivery', async () => {
|
|
||||||
const finished: boolean[] = [];
|
|
||||||
const result = await runWeightReminders(async () => false, {
|
|
||||||
list: async () => [bird('a'), bird('b')], claim: async () => true,
|
|
||||||
finish: async (_token, delivered) => { finished.push(delivered); },
|
|
||||||
});
|
|
||||||
assert.deepEqual(finished, [false, false]);
|
|
||||||
assert.equal(result.sent, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('SMTP failure releases claims and still processes other flocks', async () => {
|
|
||||||
const finished: boolean[] = [];
|
|
||||||
const result = await runWeightReminders(async (birds) => {
|
|
||||||
if (birds[0].workspace_id === 1) throw new Error('SMTP unavailable');
|
|
||||||
return true;
|
|
||||||
}, {
|
|
||||||
list: async () => [bird('a'), bird('b'), bird('c', 2)], claim: async () => true,
|
|
||||||
finish: async (_token, delivered) => { finished.push(delivered); },
|
|
||||||
});
|
|
||||||
assert.deepEqual(finished, [false, false, true]);
|
|
||||||
assert.equal(result.sent, 1);
|
|
||||||
assert.equal(result.failed, 1);
|
|
||||||
});
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
import { getReminderTimeZone } from './reminderDate.js';
|
|
||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import { db } from '../db/client.js';
|
|
||||||
|
|
||||||
export type WeightReminder = {
|
|
||||||
bird_id: string;
|
|
||||||
workspace_id: number;
|
|
||||||
bird_name: string;
|
|
||||||
species: string;
|
|
||||||
photo_data_url: string | null;
|
|
||||||
photo_object_key: string | null;
|
|
||||||
workspace_name: string;
|
|
||||||
activity_at: string;
|
|
||||||
recipient: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Use entry creation time: recorded_on is a date and cannot measure elapsed hours.
|
|
||||||
// Editing an existing entry does not reset the reminder clock.
|
|
||||||
export const listDueWeightReminders = async () => {
|
|
||||||
const result = await db.query<WeightReminder>(`
|
|
||||||
WITH activity AS (
|
|
||||||
SELECT birds.id AS bird_id, birds.workspace_id, birds.name AS bird_name,
|
|
||||||
workspaces.name AS workspace_name,
|
|
||||||
GREATEST(birds.created_at, COALESCE(MAX(weight_records.created_at), birds.created_at)) AS activity_at
|
|
||||||
FROM birds
|
|
||||||
JOIN workspaces ON workspaces.id = birds.workspace_id
|
|
||||||
LEFT JOIN weight_records ON weight_records.bird_id = birds.id
|
|
||||||
WHERE birds.memorialized_at IS NULL
|
|
||||||
GROUP BY birds.id, workspaces.name
|
|
||||||
), recipients AS (
|
|
||||||
SELECT DISTINCT workspace_id, LOWER(TRIM(users.email)) AS recipient
|
|
||||||
FROM workspace_members
|
|
||||||
JOIN users ON users.id = workspace_members.user_id
|
|
||||||
WHERE accepted_at IS NOT NULL
|
|
||||||
AND role IN ('owner', 'assistant', 'caregiver')
|
|
||||||
)
|
|
||||||
SELECT activity.bird_id, activity.workspace_id, activity.bird_name, activity.workspace_name,
|
|
||||||
activity.activity_at::text, recipients.recipient, birds.species, birds.photo_data_url, birds.photo_object_key
|
|
||||||
FROM activity
|
|
||||||
JOIN recipients USING (workspace_id)
|
|
||||||
JOIN birds ON birds.id = activity.bird_id
|
|
||||||
WHERE activity_at <= CURRENT_TIMESTAMP - INTERVAL '48 hours'
|
|
||||||
AND recipients.recipient <> ''
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM weight_reminder_deliveries d
|
|
||||||
WHERE d.bird_id = activity.bird_id AND d.workspace_id = activity.workspace_id
|
|
||||||
AND d.activity_at = activity.activity_at AND d.recipient = recipients.recipient
|
|
||||||
AND ((d.delivered_at AT TIME ZONE $1)::date >= (CURRENT_TIMESTAMP AT TIME ZONE $1)::date
|
|
||||||
OR (d.delivered_at IS NULL AND d.claimed_at > CURRENT_TIMESTAMP - INTERVAL '15 minutes'))
|
|
||||||
)
|
|
||||||
ORDER BY activity.workspace_id, activity.bird_id, recipients.recipient
|
|
||||||
`, [getReminderTimeZone()]);
|
|
||||||
return result.rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const claimWeightReminder = async (reminder: WeightReminder, token: string) => {
|
|
||||||
const result = await db.query(`
|
|
||||||
INSERT INTO weight_reminder_deliveries (bird_id, workspace_id, activity_at, recipient, claim_token)
|
|
||||||
SELECT $1, $2, $3, $4, $5
|
|
||||||
WHERE EXISTS (
|
|
||||||
SELECT 1 FROM birds
|
|
||||||
WHERE id = $1 AND workspace_id = $2 AND memorialized_at IS NULL
|
|
||||||
AND GREATEST(created_at, COALESCE((SELECT MAX(created_at) FROM weight_records WHERE bird_id = $1), created_at)) = $3::timestamptz
|
|
||||||
)
|
|
||||||
ON CONFLICT (bird_id, workspace_id, activity_at, recipient) DO UPDATE
|
|
||||||
SET claim_token = EXCLUDED.claim_token, claimed_at = CURRENT_TIMESTAMP, delivered_at = NULL
|
|
||||||
WHERE (weight_reminder_deliveries.delivered_at AT TIME ZONE $6)::date < (CURRENT_TIMESTAMP AT TIME ZONE $6)::date
|
|
||||||
OR (weight_reminder_deliveries.delivered_at IS NULL
|
|
||||||
AND weight_reminder_deliveries.claimed_at <= CURRENT_TIMESTAMP - INTERVAL '15 minutes')
|
|
||||||
RETURNING bird_id
|
|
||||||
`, [reminder.bird_id, reminder.workspace_id, reminder.activity_at, reminder.recipient, token, getReminderTimeZone()]);
|
|
||||||
return Boolean(result.rowCount);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const finishWeightReminder = async (token: string, delivered: boolean) => {
|
|
||||||
if (delivered) {
|
|
||||||
await db.query('UPDATE weight_reminder_deliveries SET delivered_at = CURRENT_TIMESTAMP WHERE claim_token = $1', [token]);
|
|
||||||
} else {
|
|
||||||
await db.query('DELETE FROM weight_reminder_deliveries WHERE claim_token = $1 AND delivered_at IS NULL', [token]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
type Dependencies = {
|
|
||||||
list: typeof listDueWeightReminders;
|
|
||||||
claim: typeof claimWeightReminder;
|
|
||||||
finish: typeof finishWeightReminder;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const runWeightReminders = async (
|
|
||||||
send: (reminders: WeightReminder[]) => Promise<boolean>,
|
|
||||||
dependencies: Dependencies = { list: listDueWeightReminders, claim: claimWeightReminder, finish: finishWeightReminder },
|
|
||||||
) => {
|
|
||||||
const reminders = await dependencies.list();
|
|
||||||
const result = { checked: reminders.length, sent: 0, skipped: 0, failed: 0 };
|
|
||||||
const groups = new Map<string, WeightReminder[]>();
|
|
||||||
for (const reminder of reminders) {
|
|
||||||
const key = JSON.stringify([reminder.workspace_id, reminder.recipient]);
|
|
||||||
groups.set(key, [...(groups.get(key) ?? []), reminder]);
|
|
||||||
}
|
|
||||||
for (const group of groups.values()) {
|
|
||||||
const claimed: { reminder: WeightReminder; token: string }[] = [];
|
|
||||||
let delivered = false;
|
|
||||||
try {
|
|
||||||
for (const reminder of group) {
|
|
||||||
const token = randomUUID();
|
|
||||||
if (await dependencies.claim(reminder, token)) claimed.push({ reminder, token });
|
|
||||||
else result.skipped += 1;
|
|
||||||
}
|
|
||||||
if (!claimed.length) continue;
|
|
||||||
delivered = await send(claimed.map(({ reminder }) => reminder));
|
|
||||||
for (const { token } of claimed) await dependencies.finish(token, delivered);
|
|
||||||
if (delivered) result.sent += 1;
|
|
||||||
else result.skipped += claimed.length;
|
|
||||||
} catch (error) {
|
|
||||||
result.failed += 1;
|
|
||||||
// Keep leases if SMTP accepted the email but saving delivery failed.
|
|
||||||
if (!delivered) {
|
|
||||||
for (const { token } of claimed) {
|
|
||||||
await dependencies.finish(token, false).catch((cleanupError) => console.error('Weight reminder claim cleanup failed', cleanupError));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.error(`Weight reminder failed for flock ${group[0].workspace_id}`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
@@ -1,422 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
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'),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
@@ -188,46 +188,6 @@ test('completePendingBirdTransfersForOwner moves pending birds and marks complet
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ rowCount: 1, rows: [] },
|
{ rowCount: 1, rows: [] },
|
||||||
{
|
|
||||||
rowCount: 1,
|
|
||||||
rows: [
|
|
||||||
{
|
|
||||||
workspace_id: 10,
|
|
||||||
workspace_name: 'Original Flock',
|
|
||||||
owner_email: 'sender@example.com',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
rowCount: 1,
|
|
||||||
rows: [
|
|
||||||
{
|
|
||||||
workspace_id: 22,
|
|
||||||
workspace_name: 'Receiving Flock',
|
|
||||||
owner_email: 'receiver@example.com',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
rowCount: 1,
|
|
||||||
rows: [
|
|
||||||
{
|
|
||||||
id: 'timeline-1',
|
|
||||||
bird_id: 'bird-1',
|
|
||||||
event_type: 'transferred',
|
|
||||||
from_workspace_id: 10,
|
|
||||||
to_workspace_id: 22,
|
|
||||||
from_workspace_name: 'Original Flock',
|
|
||||||
to_workspace_name: 'Receiving Flock',
|
|
||||||
from_owner_email: 'sender@example.com',
|
|
||||||
to_owner_email: 'receiver@example.com',
|
|
||||||
location_label: 'Receiving Flock',
|
|
||||||
location_details: null,
|
|
||||||
created_by_user_id: 'user-1',
|
|
||||||
created_at: '2026-04-15T00:00:00.000Z',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await completePendingBirdTransfersForOwner('receiver@example.com', 22);
|
const result = await completePendingBirdTransfersForOwner('receiver@example.com', 22);
|
||||||
@@ -237,19 +197,4 @@ test('completePendingBirdTransfersForOwner moves pending birds and marks complet
|
|||||||
assert.deepEqual(calls[1].params, ['bird-1', 10, 22]);
|
assert.deepEqual(calls[1].params, ['bird-1', 10, 22]);
|
||||||
assert.deepEqual(calls[2].params, ['transfer-1', 22]);
|
assert.deepEqual(calls[2].params, ['transfer-1', 22]);
|
||||||
assert.match(calls[2].text, /completed_at = CURRENT_TIMESTAMP/);
|
assert.match(calls[2].text, /completed_at = CURRENT_TIMESTAMP/);
|
||||||
assert.deepEqual(calls[5].params, [
|
|
||||||
'bird-1',
|
|
||||||
'transferred',
|
|
||||||
10,
|
|
||||||
22,
|
|
||||||
'Original Flock',
|
|
||||||
'Receiving Flock',
|
|
||||||
'sender@example.com',
|
|
||||||
'receiver@example.com',
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
'user-1',
|
|
||||||
null,
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,14 +5,9 @@ import type {
|
|||||||
BirdMilestoneReminderDeliveryRow,
|
BirdMilestoneReminderDeliveryRow,
|
||||||
BirdMilestoneReminderType,
|
BirdMilestoneReminderType,
|
||||||
BirdRow,
|
BirdRow,
|
||||||
BirdTimelineEventRow,
|
|
||||||
BirdTimelineEventType,
|
|
||||||
BirdTransferCodeRow,
|
|
||||||
LostBirdMatchRow,
|
LostBirdMatchRow,
|
||||||
MedicationAdministrationRow,
|
MedicationAdministrationRow,
|
||||||
MedicationDoseScheduleItem,
|
MedicationDoseScheduleItem,
|
||||||
MedicationReminderCandidateRow,
|
|
||||||
MedicationReminderDeliveryRow,
|
|
||||||
MedicationRow,
|
MedicationRow,
|
||||||
PendingBirdTransferRow,
|
PendingBirdTransferRow,
|
||||||
VetVisitRow,
|
VetVisitRow,
|
||||||
@@ -28,12 +23,6 @@ const birdSelectFields = `
|
|||||||
birds.motivators,
|
birds.motivators,
|
||||||
birds.demotivators,
|
birds.demotivators,
|
||||||
birds.favorite_snack,
|
birds.favorite_snack,
|
||||||
birds.location_label,
|
|
||||||
birds.location_details,
|
|
||||||
birds.vet_clinic_name,
|
|
||||||
birds.vet_clinic_address,
|
|
||||||
birds.vet_account_number,
|
|
||||||
birds.vet_doctor_name,
|
|
||||||
birds.gender,
|
birds.gender,
|
||||||
birds.date_of_birth::text,
|
birds.date_of_birth::text,
|
||||||
birds.gotcha_day::text,
|
birds.gotcha_day::text,
|
||||||
@@ -55,34 +44,6 @@ const birdSelectFields = `
|
|||||||
latest.recorded_on::text AS latest_recorded_on
|
latest.recorded_on::text AS latest_recorded_on
|
||||||
`;
|
`;
|
||||||
|
|
||||||
type WorkspaceTimelineSnapshot = {
|
|
||||||
workspace_id: number;
|
|
||||||
workspace_name: string;
|
|
||||||
owner_email: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getWorkspaceTimelineSnapshot = async (workspaceId: number) => {
|
|
||||||
const result = await db.query<WorkspaceTimelineSnapshot>(
|
|
||||||
`SELECT
|
|
||||||
workspaces.id AS workspace_id,
|
|
||||||
workspaces.name AS workspace_name,
|
|
||||||
COALESCE(workspaces.billing_email, owner_member.invite_email, owner_member.email) AS owner_email
|
|
||||||
FROM workspaces
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT invite_email, email
|
|
||||||
FROM workspace_members
|
|
||||||
WHERE workspace_members.workspace_id = workspaces.id
|
|
||||||
AND workspace_members.role = 'owner'
|
|
||||||
ORDER BY accepted_at DESC NULLS LAST, created_at ASC
|
|
||||||
LIMIT 1
|
|
||||||
) owner_member ON TRUE
|
|
||||||
WHERE workspaces.id = $1`,
|
|
||||||
[workspaceId],
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.rows[0] ?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getBirdById = async (birdId: string, workspaceId: number) => {
|
export const getBirdById = async (birdId: string, workspaceId: number) => {
|
||||||
const result = await db.query<BirdRow>(
|
const result = await db.query<BirdRow>(
|
||||||
`SELECT
|
`SELECT
|
||||||
@@ -166,102 +127,6 @@ export const listMemorializedBirds = async (workspaceId: number) => {
|
|||||||
return result.rows;
|
return result.rows;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createBirdTimelineEvent = async ({
|
|
||||||
birdId,
|
|
||||||
eventType,
|
|
||||||
fromWorkspaceId,
|
|
||||||
toWorkspaceId,
|
|
||||||
locationLabel,
|
|
||||||
locationDetails,
|
|
||||||
note,
|
|
||||||
eventDate,
|
|
||||||
createdByUserId,
|
|
||||||
}: {
|
|
||||||
birdId: string;
|
|
||||||
eventType: BirdTimelineEventType;
|
|
||||||
fromWorkspaceId?: number | null;
|
|
||||||
toWorkspaceId?: number | null;
|
|
||||||
locationLabel?: string | null;
|
|
||||||
locationDetails?: Record<string, unknown> | null;
|
|
||||||
note?: string | null;
|
|
||||||
eventDate?: string | null;
|
|
||||||
createdByUserId?: string | null;
|
|
||||||
}) => {
|
|
||||||
const [fromWorkspace, toWorkspace] = await Promise.all([
|
|
||||||
fromWorkspaceId ? getWorkspaceTimelineSnapshot(fromWorkspaceId) : Promise.resolve(null),
|
|
||||||
toWorkspaceId ? getWorkspaceTimelineSnapshot(toWorkspaceId) : Promise.resolve(null),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const result = await db.query<BirdTimelineEventRow>(
|
|
||||||
`INSERT INTO bird_timeline_events (
|
|
||||||
bird_id,
|
|
||||||
event_type,
|
|
||||||
from_workspace_id,
|
|
||||||
to_workspace_id,
|
|
||||||
from_workspace_name,
|
|
||||||
to_workspace_name,
|
|
||||||
from_owner_email,
|
|
||||||
to_owner_email,
|
|
||||||
location_label,
|
|
||||||
note,
|
|
||||||
event_date,
|
|
||||||
created_by_user_id,
|
|
||||||
location_details
|
|
||||||
)
|
|
||||||
VALUES (
|
|
||||||
$1,
|
|
||||||
$2,
|
|
||||||
$3,
|
|
||||||
$4,
|
|
||||||
$5::varchar(160),
|
|
||||||
$6::varchar(160),
|
|
||||||
$7::varchar(320),
|
|
||||||
$8::varchar(320),
|
|
||||||
COALESCE($9::varchar(160), $6::varchar(160), $5::varchar(160)),
|
|
||||||
$10,
|
|
||||||
COALESCE($11::date, CURRENT_DATE),
|
|
||||||
$12,
|
|
||||||
$13
|
|
||||||
)
|
|
||||||
RETURNING id, bird_id, event_type, from_workspace_id, to_workspace_id, from_workspace_name, to_workspace_name, from_owner_email, to_owner_email, location_label, location_details, note, event_date::text, created_by_user_id, created_at`,
|
|
||||||
[
|
|
||||||
birdId,
|
|
||||||
eventType,
|
|
||||||
fromWorkspaceId ?? null,
|
|
||||||
toWorkspaceId ?? null,
|
|
||||||
fromWorkspace?.workspace_name ?? null,
|
|
||||||
toWorkspace?.workspace_name ?? null,
|
|
||||||
fromWorkspace?.owner_email ?? null,
|
|
||||||
toWorkspace?.owner_email ?? null,
|
|
||||||
locationLabel ?? null,
|
|
||||||
note ?? null,
|
|
||||||
eventDate ?? null,
|
|
||||||
createdByUserId ?? null,
|
|
||||||
locationDetails ?? null,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.rows[0] ?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const listBirdTimelineEvents = async (birdId: string, workspaceId: number) => {
|
|
||||||
const result = await db.query<BirdTimelineEventRow>(
|
|
||||||
`SELECT id, bird_id, event_type, from_workspace_id, to_workspace_id, from_workspace_name, to_workspace_name, from_owner_email, to_owner_email, location_label, location_details, note, event_date::text, created_by_user_id, created_at
|
|
||||||
FROM bird_timeline_events
|
|
||||||
WHERE bird_id = $1
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM birds
|
|
||||||
WHERE birds.id = bird_timeline_events.bird_id
|
|
||||||
AND birds.workspace_id = $2
|
|
||||||
)
|
|
||||||
ORDER BY event_date DESC, created_at DESC`,
|
|
||||||
[birdId, workspaceId],
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const findBirdsByBandId = async (tagId: string) => {
|
export const findBirdsByBandId = async (tagId: string) => {
|
||||||
const result = await db.query<LostBirdMatchRow>(
|
const result = await db.query<LostBirdMatchRow>(
|
||||||
`SELECT
|
`SELECT
|
||||||
@@ -413,79 +278,6 @@ export const createBirdMilestoneReminderDelivery = async ({
|
|||||||
return result.rows[0] ?? null;
|
return result.rows[0] ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const listDueMedicationReminders = async (runDate: string, currentTime: string) => {
|
|
||||||
const result = await db.query<MedicationReminderCandidateRow>(
|
|
||||||
`SELECT
|
|
||||||
${birdSelectFields},
|
|
||||||
workspaces.name AS workspace_name,
|
|
||||||
medications.id AS medication_id,
|
|
||||||
medications.name AS medication_name,
|
|
||||||
medications.dosage,
|
|
||||||
medications.frequency,
|
|
||||||
medications.dose_schedule,
|
|
||||||
medications.route,
|
|
||||||
medications.start_date::text AS medication_start_date,
|
|
||||||
medications.end_date::text AS medication_end_date,
|
|
||||||
medications.notes AS medication_notes,
|
|
||||||
$1::date::text AS scheduled_on,
|
|
||||||
dose.key AS administration_slot,
|
|
||||||
dose.label AS administration_label,
|
|
||||||
dose.time AS administration_time
|
|
||||||
FROM medications
|
|
||||||
INNER JOIN birds ON birds.id = medications.bird_id
|
|
||||||
INNER JOIN workspaces ON workspaces.id = birds.workspace_id
|
|
||||||
CROSS JOIN LATERAL jsonb_to_recordset(medications.dose_schedule) AS dose(key text, label text, time text)
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT weight_grams, recorded_on
|
|
||||||
FROM weight_records
|
|
||||||
WHERE weight_records.bird_id = birds.id
|
|
||||||
ORDER BY recorded_on DESC
|
|
||||||
LIMIT 1
|
|
||||||
) latest ON TRUE
|
|
||||||
WHERE medications.reminders_enabled = TRUE
|
|
||||||
AND birds.memorialized_at IS NULL
|
|
||||||
AND medications.start_date <= $1::date
|
|
||||||
AND (medications.end_date IS NULL OR medications.end_date >= $1::date)
|
|
||||||
AND COALESCE(NULLIF(BTRIM(dose.time), ''), '') <> ''
|
|
||||||
AND dose.time <= $2
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM medication_reminder_deliveries deliveries
|
|
||||||
WHERE deliveries.medication_id = medications.id
|
|
||||||
AND deliveries.scheduled_on = $1::date
|
|
||||||
AND deliveries.administration_slot = dose.key
|
|
||||||
)
|
|
||||||
ORDER BY workspaces.name ASC, birds.name ASC, dose.time ASC, medications.name ASC`,
|
|
||||||
[runDate, currentTime],
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createMedicationReminderDelivery = async ({
|
|
||||||
medicationId,
|
|
||||||
birdId,
|
|
||||||
workspaceId,
|
|
||||||
scheduledOn,
|
|
||||||
administrationSlot,
|
|
||||||
}: {
|
|
||||||
medicationId: string;
|
|
||||||
birdId: string;
|
|
||||||
workspaceId: number;
|
|
||||||
scheduledOn: string;
|
|
||||||
administrationSlot: string;
|
|
||||||
}) => {
|
|
||||||
const result = await db.query<MedicationReminderDeliveryRow>(
|
|
||||||
`INSERT INTO medication_reminder_deliveries (medication_id, bird_id, workspace_id, scheduled_on, administration_slot)
|
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
|
||||||
ON CONFLICT (medication_id, scheduled_on, administration_slot) DO NOTHING
|
|
||||||
RETURNING id, medication_id, bird_id, workspace_id, scheduled_on::text, administration_slot, delivered_at`,
|
|
||||||
[medicationId, birdId, workspaceId, scheduledOn, administrationSlot],
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.rows[0] ?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createBird = async ({
|
export const createBird = async ({
|
||||||
birdId,
|
birdId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -495,12 +287,6 @@ export const createBird = async ({
|
|||||||
motivators,
|
motivators,
|
||||||
demotivators,
|
demotivators,
|
||||||
favoriteSnack,
|
favoriteSnack,
|
||||||
locationLabel = null,
|
|
||||||
locationDetails = null,
|
|
||||||
vetClinicName = null,
|
|
||||||
vetClinicAddress = null,
|
|
||||||
vetAccountNumber = null,
|
|
||||||
vetDoctorName = null,
|
|
||||||
gender,
|
gender,
|
||||||
dateOfBirth,
|
dateOfBirth,
|
||||||
gotchaDay,
|
gotchaDay,
|
||||||
@@ -522,12 +308,6 @@ export const createBird = async ({
|
|||||||
motivators: string | null;
|
motivators: string | null;
|
||||||
demotivators: string | null;
|
demotivators: string | null;
|
||||||
favoriteSnack: string | null;
|
favoriteSnack: string | null;
|
||||||
locationLabel?: string | null;
|
|
||||||
locationDetails?: Record<string, unknown> | null;
|
|
||||||
vetClinicName?: string | null;
|
|
||||||
vetClinicAddress?: string | null;
|
|
||||||
vetAccountNumber?: string | null;
|
|
||||||
vetDoctorName?: string | null;
|
|
||||||
gender: BirdGender;
|
gender: BirdGender;
|
||||||
dateOfBirth: string | null;
|
dateOfBirth: string | null;
|
||||||
gotchaDay: string | null;
|
gotchaDay: string | null;
|
||||||
@@ -542,9 +322,9 @@ export const createBird = async ({
|
|||||||
publicProfileEnabled?: boolean;
|
publicProfileEnabled?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const result = await db.query<BirdRow>(
|
const result = await db.query<BirdRow>(
|
||||||
`INSERT INTO birds (id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, 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)
|
`INSERT INTO birds (id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, gender, date_of_birth, gotcha_day, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled)
|
||||||
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)
|
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)
|
||||||
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`,
|
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`,
|
||||||
[
|
[
|
||||||
birdId ?? null,
|
birdId ?? null,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -554,12 +334,6 @@ export const createBird = async ({
|
|||||||
motivators,
|
motivators,
|
||||||
demotivators,
|
demotivators,
|
||||||
favoriteSnack,
|
favoriteSnack,
|
||||||
locationLabel,
|
|
||||||
locationDetails,
|
|
||||||
vetClinicName,
|
|
||||||
vetClinicAddress,
|
|
||||||
vetAccountNumber,
|
|
||||||
vetDoctorName,
|
|
||||||
gender,
|
gender,
|
||||||
dateOfBirth,
|
dateOfBirth,
|
||||||
gotchaDay,
|
gotchaDay,
|
||||||
@@ -587,12 +361,6 @@ export const updateBird = async ({
|
|||||||
motivators,
|
motivators,
|
||||||
demotivators,
|
demotivators,
|
||||||
favoriteSnack,
|
favoriteSnack,
|
||||||
locationLabel,
|
|
||||||
locationDetails,
|
|
||||||
vetClinicName,
|
|
||||||
vetClinicAddress,
|
|
||||||
vetAccountNumber,
|
|
||||||
vetDoctorName,
|
|
||||||
gender,
|
gender,
|
||||||
dateOfBirth,
|
dateOfBirth,
|
||||||
gotchaDay,
|
gotchaDay,
|
||||||
@@ -614,12 +382,6 @@ export const updateBird = async ({
|
|||||||
motivators: string | null;
|
motivators: string | null;
|
||||||
demotivators: string | null;
|
demotivators: string | null;
|
||||||
favoriteSnack: string | null;
|
favoriteSnack: string | null;
|
||||||
locationLabel: string | null;
|
|
||||||
locationDetails?: Record<string, unknown> | null;
|
|
||||||
vetClinicName: string | null;
|
|
||||||
vetClinicAddress: string | null;
|
|
||||||
vetAccountNumber: string | null;
|
|
||||||
vetDoctorName: string | null;
|
|
||||||
gender: BirdGender;
|
gender: BirdGender;
|
||||||
dateOfBirth: string | null;
|
dateOfBirth: string | null;
|
||||||
gotchaDay: string | null;
|
gotchaDay: string | null;
|
||||||
@@ -641,28 +403,22 @@ export const updateBird = async ({
|
|||||||
motivators = $5,
|
motivators = $5,
|
||||||
demotivators = $6,
|
demotivators = $6,
|
||||||
favorite_snack = $7,
|
favorite_snack = $7,
|
||||||
location_label = $8,
|
gender = $8,
|
||||||
vet_clinic_name = $9,
|
date_of_birth = $9,
|
||||||
vet_clinic_address = $10,
|
gotcha_day = $10,
|
||||||
vet_account_number = $11,
|
chart_color = $11,
|
||||||
vet_doctor_name = $12,
|
photo_data_url = $12,
|
||||||
gender = $13,
|
photo_object_key = $13,
|
||||||
date_of_birth = $14,
|
photo_content_type = $14,
|
||||||
gotcha_day = $15,
|
photo_updated_at = $15,
|
||||||
chart_color = $16,
|
notify_on_dob = $16,
|
||||||
photo_data_url = $17,
|
notify_on_gotcha_day = $17,
|
||||||
photo_object_key = $18,
|
public_profile_code = $18,
|
||||||
photo_content_type = $19,
|
public_profile_enabled = $19
|
||||||
photo_updated_at = $20,
|
|
||||||
notify_on_dob = $21,
|
|
||||||
notify_on_gotcha_day = $22,
|
|
||||||
public_profile_code = $23,
|
|
||||||
public_profile_enabled = $24,
|
|
||||||
location_details = $25
|
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND workspace_id = $26
|
AND workspace_id = $20
|
||||||
AND memorialized_at IS NULL
|
AND memorialized_at IS NULL
|
||||||
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,
|
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
||||||
(
|
(
|
||||||
SELECT weight_grams::text
|
SELECT weight_grams::text
|
||||||
FROM weight_records
|
FROM weight_records
|
||||||
@@ -685,11 +441,6 @@ export const updateBird = async ({
|
|||||||
motivators,
|
motivators,
|
||||||
demotivators,
|
demotivators,
|
||||||
favoriteSnack,
|
favoriteSnack,
|
||||||
locationLabel,
|
|
||||||
vetClinicName,
|
|
||||||
vetClinicAddress,
|
|
||||||
vetAccountNumber,
|
|
||||||
vetDoctorName,
|
|
||||||
gender,
|
gender,
|
||||||
dateOfBirth,
|
dateOfBirth,
|
||||||
gotchaDay,
|
gotchaDay,
|
||||||
@@ -702,7 +453,6 @@ export const updateBird = async ({
|
|||||||
notifyOnGotchaDay,
|
notifyOnGotchaDay,
|
||||||
publicProfileCode,
|
publicProfileCode,
|
||||||
publicProfileEnabled,
|
publicProfileEnabled,
|
||||||
locationDetails ?? null,
|
|
||||||
workspaceId,
|
workspaceId,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -732,7 +482,7 @@ export const memorializeBird = async ({
|
|||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND workspace_id = $2
|
AND workspace_id = $2
|
||||||
AND memorialized_at IS NULL
|
AND memorialized_at IS NULL
|
||||||
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, 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,
|
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,
|
||||||
(
|
(
|
||||||
SELECT weight_grams::text
|
SELECT weight_grams::text
|
||||||
FROM weight_records
|
FROM weight_records
|
||||||
@@ -768,7 +518,7 @@ export const updateMemorialReminderPreference = async ({
|
|||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND workspace_id = $2
|
AND workspace_id = $2
|
||||||
AND memorialized_at IS NOT NULL
|
AND memorialized_at IS NOT NULL
|
||||||
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, 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,
|
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,
|
||||||
(
|
(
|
||||||
SELECT weight_grams::text
|
SELECT weight_grams::text
|
||||||
FROM weight_records
|
FROM weight_records
|
||||||
@@ -808,7 +558,7 @@ export const transferBirdToWorkspace = async (birdId: string, sourceWorkspaceId:
|
|||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND workspace_id = $2
|
AND workspace_id = $2
|
||||||
AND memorialized_at IS NULL
|
AND memorialized_at IS NULL
|
||||||
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, 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,
|
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,
|
||||||
(
|
(
|
||||||
SELECT weight_grams::text
|
SELECT weight_grams::text
|
||||||
FROM weight_records
|
FROM weight_records
|
||||||
@@ -904,23 +654,12 @@ export const completePendingBirdTransfersForOwner = async (ownerEmail: string, t
|
|||||||
}
|
}
|
||||||
|
|
||||||
await markPendingBirdTransferCompleted(transfer.id, targetWorkspaceId);
|
await markPendingBirdTransferCompleted(transfer.id, targetWorkspaceId);
|
||||||
try {
|
|
||||||
await createBirdTimelineEvent({
|
|
||||||
birdId: bird.id,
|
|
||||||
eventType: 'transferred',
|
|
||||||
fromWorkspaceId: transfer.source_workspace_id,
|
|
||||||
toWorkspaceId: targetWorkspaceId,
|
|
||||||
createdByUserId: transfer.requested_by_user_id,
|
|
||||||
});
|
|
||||||
} catch (timelineError) {
|
|
||||||
console.error('Unable to write bird timeline event', timelineError);
|
|
||||||
}
|
|
||||||
completed += 1;
|
completed += 1;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failed += 1;
|
failed += 1;
|
||||||
const message =
|
const message =
|
||||||
typeof error === 'object' && error && 'code' in error && error.code === '23505'
|
typeof error === 'object' && error && 'code' in error && error.code === '23505'
|
||||||
? 'That band/tag ID is already in use in FlockPal.'
|
? 'The receiving flock already has a bird using the same band/tag ID.'
|
||||||
: error instanceof Error
|
: error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: 'Unable to complete pending bird transfer.';
|
: 'Unable to complete pending bird transfer.';
|
||||||
@@ -931,93 +670,6 @@ export const completePendingBirdTransfersForOwner = async (ownerEmail: string, t
|
|||||||
return { completed, failed };
|
return { completed, failed };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createBirdTransferCode = async ({
|
|
||||||
code,
|
|
||||||
birdId,
|
|
||||||
sourceWorkspaceId,
|
|
||||||
requestedByUserId,
|
|
||||||
}: {
|
|
||||||
code: string;
|
|
||||||
birdId: string;
|
|
||||||
sourceWorkspaceId: number;
|
|
||||||
requestedByUserId: string;
|
|
||||||
}) => {
|
|
||||||
await db.query(
|
|
||||||
`UPDATE bird_transfer_codes
|
|
||||||
SET revoked_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE bird_id = $1
|
|
||||||
AND source_workspace_id = $2
|
|
||||||
AND completed_at IS NULL
|
|
||||||
AND revoked_at IS NULL`,
|
|
||||||
[birdId, sourceWorkspaceId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await db.query<BirdTransferCodeRow>(
|
|
||||||
`INSERT INTO bird_transfer_codes (code, bird_id, source_workspace_id, requested_by_user_id)
|
|
||||||
VALUES ($1, $2, $3, $4)
|
|
||||||
RETURNING id, code, bird_id, source_workspace_id, requested_by_user_id, completed_at::text, completed_workspace_id, revoked_at::text, created_at`,
|
|
||||||
[code, birdId, sourceWorkspaceId, requestedByUserId],
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.rows[0] ?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getOpenBirdTransferCode = async (code: string) => {
|
|
||||||
const result = await db.query<
|
|
||||||
BirdRow & {
|
|
||||||
transfer_code_id: string;
|
|
||||||
code: string;
|
|
||||||
source_workspace_id: number;
|
|
||||||
requested_by_user_id: string;
|
|
||||||
completed_at: string | null;
|
|
||||||
completed_workspace_id: number | null;
|
|
||||||
revoked_at: string | null;
|
|
||||||
transfer_code_created_at: string;
|
|
||||||
workspace_name: string;
|
|
||||||
}
|
|
||||||
>(
|
|
||||||
`SELECT
|
|
||||||
bird_transfer_codes.id AS transfer_code_id,
|
|
||||||
bird_transfer_codes.code,
|
|
||||||
bird_transfer_codes.source_workspace_id,
|
|
||||||
bird_transfer_codes.requested_by_user_id,
|
|
||||||
bird_transfer_codes.completed_at::text,
|
|
||||||
bird_transfer_codes.completed_workspace_id,
|
|
||||||
bird_transfer_codes.revoked_at::text,
|
|
||||||
bird_transfer_codes.created_at AS transfer_code_created_at,
|
|
||||||
workspaces.name AS workspace_name,
|
|
||||||
${birdSelectFields}
|
|
||||||
FROM bird_transfer_codes
|
|
||||||
INNER JOIN birds ON birds.id = bird_transfer_codes.bird_id
|
|
||||||
INNER JOIN workspaces ON workspaces.id = bird_transfer_codes.source_workspace_id
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT weight_grams, recorded_on
|
|
||||||
FROM weight_records
|
|
||||||
WHERE weight_records.bird_id = birds.id
|
|
||||||
ORDER BY recorded_on DESC
|
|
||||||
LIMIT 1
|
|
||||||
) latest ON TRUE
|
|
||||||
WHERE bird_transfer_codes.code = $1
|
|
||||||
AND bird_transfer_codes.completed_at IS NULL
|
|
||||||
AND bird_transfer_codes.revoked_at IS NULL
|
|
||||||
AND birds.workspace_id = bird_transfer_codes.source_workspace_id
|
|
||||||
AND birds.memorialized_at IS NULL`,
|
|
||||||
[code],
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.rows[0] ?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const markBirdTransferCodeCompleted = async (codeId: string, completedWorkspaceId: number) => {
|
|
||||||
await db.query(
|
|
||||||
`UPDATE bird_transfer_codes
|
|
||||||
SET completed_at = CURRENT_TIMESTAMP,
|
|
||||||
completed_workspace_id = $2
|
|
||||||
WHERE id = $1`,
|
|
||||||
[codeId, completedWorkspaceId],
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const listWeightsForBird = async (birdId: string, workspaceId: number, days: number) => {
|
export const listWeightsForBird = async (birdId: string, workspaceId: number, days: number) => {
|
||||||
const result = await db.query<WeightRow>(
|
const result = await db.query<WeightRow>(
|
||||||
`SELECT id, bird_id, weight_grams, recorded_on::text, notes
|
`SELECT id, bird_id, weight_grams, recorded_on::text, notes
|
||||||
@@ -1048,34 +700,6 @@ export const createWeightForBird = async (birdId: string, weightGrams: number, r
|
|||||||
return result.rows[0] ?? null;
|
return result.rows[0] ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateWeightForBird = async (
|
|
||||||
weightId: string,
|
|
||||||
birdId: string,
|
|
||||||
weightGrams: number,
|
|
||||||
recordedOn: string,
|
|
||||||
notes: string | null,
|
|
||||||
) => {
|
|
||||||
const result = await db.query<WeightRow>(
|
|
||||||
`UPDATE weight_records
|
|
||||||
SET weight_grams = $3,
|
|
||||||
recorded_on = $4,
|
|
||||||
notes = $5
|
|
||||||
WHERE id = $1
|
|
||||||
AND bird_id = $2
|
|
||||||
AND id IN (
|
|
||||||
SELECT recent.id
|
|
||||||
FROM weight_records recent
|
|
||||||
WHERE recent.bird_id = $2
|
|
||||||
ORDER BY recent.recorded_on DESC, recent.created_at DESC
|
|
||||||
LIMIT 3
|
|
||||||
)
|
|
||||||
RETURNING id, bird_id, weight_grams, recorded_on::text, notes`,
|
|
||||||
[weightId, birdId, weightGrams, recordedOn, notes],
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.rows[0] ?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const listVetVisitsForBird = async (birdId: string, workspaceId: number) => {
|
export const listVetVisitsForBird = async (birdId: string, workspaceId: number) => {
|
||||||
const result = await db.query<VetVisitRow>(
|
const result = await db.query<VetVisitRow>(
|
||||||
`SELECT id, bird_id, visited_on::text, clinic_name, reason, notes
|
`SELECT id, bird_id, visited_on::text, clinic_name, reason, notes
|
||||||
@@ -1142,7 +766,7 @@ export const deleteVetVisitForBird = async (visitId: string, birdId: string) =>
|
|||||||
|
|
||||||
export const listMedicationsForBird = async (birdId: string, workspaceId: number) => {
|
export const listMedicationsForBird = async (birdId: string, workspaceId: number) => {
|
||||||
const result = await db.query<MedicationRow>(
|
const result = await db.query<MedicationRow>(
|
||||||
`SELECT id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes, reminders_enabled
|
`SELECT id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes
|
||||||
FROM medications
|
FROM medications
|
||||||
WHERE bird_id = $1
|
WHERE bird_id = $1
|
||||||
AND EXISTS (
|
AND EXISTS (
|
||||||
@@ -1168,13 +792,12 @@ export const createMedicationForBird = async (
|
|||||||
startDate: string,
|
startDate: string,
|
||||||
endDate: string | null,
|
endDate: string | null,
|
||||||
notes: string | null,
|
notes: string | null,
|
||||||
remindersEnabled: boolean,
|
|
||||||
) => {
|
) => {
|
||||||
const result = await db.query<MedicationRow>(
|
const result = await db.query<MedicationRow>(
|
||||||
`INSERT INTO medications (bird_id, name, dosage, frequency, dose_schedule, route, start_date, end_date, notes, reminders_enabled)
|
`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, $10)
|
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, reminders_enabled`,
|
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, remindersEnabled],
|
[birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, notes],
|
||||||
);
|
);
|
||||||
|
|
||||||
return result.rows[0] ?? null;
|
return result.rows[0] ?? null;
|
||||||
@@ -1191,7 +814,6 @@ export const updateMedicationForBird = async (
|
|||||||
startDate: string,
|
startDate: string,
|
||||||
endDate: string | null,
|
endDate: string | null,
|
||||||
notes: string | null,
|
notes: string | null,
|
||||||
remindersEnabled: boolean,
|
|
||||||
) => {
|
) => {
|
||||||
const result = await db.query<MedicationRow>(
|
const result = await db.query<MedicationRow>(
|
||||||
`UPDATE medications
|
`UPDATE medications
|
||||||
@@ -1202,12 +824,11 @@ export const updateMedicationForBird = async (
|
|||||||
route = $7,
|
route = $7,
|
||||||
start_date = $8,
|
start_date = $8,
|
||||||
end_date = $9,
|
end_date = $9,
|
||||||
notes = $10,
|
notes = $10
|
||||||
reminders_enabled = $11
|
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND bird_id = $2
|
AND bird_id = $2
|
||||||
RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes, reminders_enabled`,
|
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, remindersEnabled],
|
[medicationId, birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, notes],
|
||||||
);
|
);
|
||||||
|
|
||||||
return result.rows[0] ?? null;
|
return result.rows[0] ?? null;
|
||||||
|
|||||||
@@ -1,153 +0,0 @@
|
|||||||
import { db } from '../db/client.js';
|
|
||||||
import type { DailyEducationQuestion, DailyEducationRow, EducationQuestionRow } from '../types.js';
|
|
||||||
|
|
||||||
export const getEducationOptOut = async (userId: string) => {
|
|
||||||
const result = await db.query<{ education_opt_out: boolean }>(
|
|
||||||
`SELECT education_opt_out
|
|
||||||
FROM users
|
|
||||||
WHERE id = $1`,
|
|
||||||
[userId],
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.rows[0]?.education_opt_out ?? false;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateEducationOptOut = async (userId: string, educationOptOut: boolean) => {
|
|
||||||
const result = await db.query<{ education_opt_out: boolean }>(
|
|
||||||
`UPDATE users
|
|
||||||
SET education_opt_out = $2
|
|
||||||
WHERE id = $1
|
|
||||||
RETURNING education_opt_out`,
|
|
||||||
[userId, educationOptOut],
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.rows[0]?.education_opt_out ?? educationOptOut;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getDailyEducationForDate = async (publishDate?: string) => {
|
|
||||||
const result = publishDate
|
|
||||||
? await db.query<DailyEducationRow>(
|
|
||||||
`SELECT id, publish_date::text, fact, quiz_questions, created_by_user_id, created_at, updated_at
|
|
||||||
FROM daily_education
|
|
||||||
WHERE publish_date = $1`,
|
|
||||||
[publishDate],
|
|
||||||
)
|
|
||||||
: await db.query<DailyEducationRow>(
|
|
||||||
`SELECT id, publish_date::text, fact, quiz_questions, created_by_user_id, created_at, updated_at
|
|
||||||
FROM daily_education
|
|
||||||
WHERE publish_date = CURRENT_DATE`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.rows[0] ?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const listDailyEducationForAdmin = async () => {
|
|
||||||
const result = await db.query<DailyEducationRow>(
|
|
||||||
`SELECT id, publish_date::text, fact, quiz_questions, created_by_user_id, created_at, updated_at
|
|
||||||
FROM daily_education
|
|
||||||
ORDER BY publish_date DESC
|
|
||||||
LIMIT 120`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const upsertDailyEducation = async ({
|
|
||||||
publishDate,
|
|
||||||
fact,
|
|
||||||
createdByUserId,
|
|
||||||
}: {
|
|
||||||
publishDate: string;
|
|
||||||
fact: string;
|
|
||||||
createdByUserId: string;
|
|
||||||
}) => {
|
|
||||||
const result = await db.query<DailyEducationRow>(
|
|
||||||
`INSERT INTO daily_education (publish_date, fact, created_by_user_id)
|
|
||||||
VALUES ($1, $2, $3)
|
|
||||||
ON CONFLICT (publish_date) DO UPDATE
|
|
||||||
SET fact = EXCLUDED.fact,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
RETURNING id, publish_date::text, fact, quiz_questions, created_by_user_id, created_at, updated_at`,
|
|
||||||
[publishDate, fact, createdByUserId],
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.rows[0];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const listEducationQuestionsForAdmin = async () => {
|
|
||||||
const result = await db.query<EducationQuestionRow>(
|
|
||||||
`SELECT id, prompt, options, correct_answer_index, explanation, created_by_user_id, created_at, updated_at
|
|
||||||
FROM education_question_bank
|
|
||||||
ORDER BY updated_at DESC, created_at DESC
|
|
||||||
LIMIT 400`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const listDailyEducationQuestions = async (seedDate?: string) => {
|
|
||||||
const result = await db.query<EducationQuestionRow>(
|
|
||||||
`SELECT id, prompt, options, correct_answer_index, explanation, created_by_user_id, created_at, updated_at
|
|
||||||
FROM education_question_bank
|
|
||||||
ORDER BY md5(COALESCE($1::text, CURRENT_DATE::text) || id::text)
|
|
||||||
LIMIT 4`,
|
|
||||||
[seedDate ?? null],
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createEducationQuestion = async ({
|
|
||||||
question,
|
|
||||||
createdByUserId,
|
|
||||||
}: {
|
|
||||||
question: DailyEducationQuestion;
|
|
||||||
createdByUserId: string;
|
|
||||||
}) => {
|
|
||||||
const result = await db.query<EducationQuestionRow>(
|
|
||||||
`INSERT INTO education_question_bank (prompt, options, correct_answer_index, explanation, created_by_user_id)
|
|
||||||
VALUES ($1, $2::jsonb, $3, $4, $5)
|
|
||||||
RETURNING id, prompt, options, correct_answer_index, explanation, created_by_user_id, created_at, updated_at`,
|
|
||||||
[question.prompt, JSON.stringify(question.options), question.correctAnswerIndex, question.explanation, createdByUserId],
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.rows[0];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateEducationQuestion = async (questionId: string, question: DailyEducationQuestion) => {
|
|
||||||
const result = await db.query<EducationQuestionRow>(
|
|
||||||
`UPDATE education_question_bank
|
|
||||||
SET prompt = $2,
|
|
||||||
options = $3::jsonb,
|
|
||||||
correct_answer_index = $4,
|
|
||||||
explanation = $5,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = $1
|
|
||||||
RETURNING id, prompt, options, correct_answer_index, explanation, created_by_user_id, created_at, updated_at`,
|
|
||||||
[questionId, question.prompt, JSON.stringify(question.options), question.correctAnswerIndex, question.explanation],
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.rows[0] ?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteEducationQuestion = async (questionId: string) => {
|
|
||||||
const result = await db.query<{ id: string }>(
|
|
||||||
`DELETE FROM education_question_bank
|
|
||||||
WHERE id = $1
|
|
||||||
RETURNING id`,
|
|
||||||
[questionId],
|
|
||||||
);
|
|
||||||
|
|
||||||
return Boolean(result.rowCount);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteDailyEducation = async (educationId: string) => {
|
|
||||||
const result = await db.query<{ id: string }>(
|
|
||||||
`DELETE FROM daily_education
|
|
||||||
WHERE id = $1
|
|
||||||
RETURNING id`,
|
|
||||||
[educationId],
|
|
||||||
);
|
|
||||||
|
|
||||||
return Boolean(result.rowCount);
|
|
||||||
};
|
|
||||||
@@ -3,7 +3,6 @@ import test from 'node:test';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
createWorkspace,
|
createWorkspace,
|
||||||
deleteWorkspaceMember,
|
|
||||||
deleteWorkspaceIfEmpty,
|
deleteWorkspaceIfEmpty,
|
||||||
ensureDefaultWorkspaceForUser,
|
ensureDefaultWorkspaceForUser,
|
||||||
ensurePersonalWorkspaceForUser,
|
ensurePersonalWorkspaceForUser,
|
||||||
@@ -11,7 +10,6 @@ import {
|
|||||||
getPlatformAdminSummary,
|
getPlatformAdminSummary,
|
||||||
listOwnedWorkspacesByOwnerEmail,
|
listOwnedWorkspacesByOwnerEmail,
|
||||||
updateWorkspace,
|
updateWorkspace,
|
||||||
updateWorkspaceMemberRole,
|
|
||||||
} from './workspaceRepository.js';
|
} from './workspaceRepository.js';
|
||||||
import { mockDb } from '../test/mockDb.js';
|
import { mockDb } from '../test/mockDb.js';
|
||||||
import type { UserRow } from '../types.js';
|
import type { UserRow } from '../types.js';
|
||||||
@@ -261,263 +259,6 @@ test('listOwnedWorkspacesByOwnerEmail resolves accepted owner flocks by email',
|
|||||||
assert.match(calls[0].text, /workspaces\.id <> \$2/);
|
assert.match(calls[0].text, /workspaces\.id <> \$2/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('updateWorkspaceMemberRole changes a non-owner member role', async () => {
|
|
||||||
const { calls } = mockDb({
|
|
||||||
rowCount: 1,
|
|
||||||
rows: [
|
|
||||||
{
|
|
||||||
id: 'member-1',
|
|
||||||
workspace_id: 42,
|
|
||||||
user_id: 'user-2',
|
|
||||||
invite_email: 'helper@example.com',
|
|
||||||
name: 'Helper',
|
|
||||||
role: 'viewer',
|
|
||||||
accepted_at: '2026-04-14T00:00:00.000Z',
|
|
||||||
created_at: '2026-04-14T00:00:00.000Z',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const member = await updateWorkspaceMemberRole({
|
|
||||||
memberId: 'member-1',
|
|
||||||
workspaceId: 42,
|
|
||||||
role: 'viewer',
|
|
||||||
requesterMemberId: 'owner-member',
|
|
||||||
requesterIsBillingOwner: false,
|
|
||||||
requesterRole: 'owner',
|
|
||||||
billingEmail: 'billing@example.com',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(member?.role, 'viewer');
|
|
||||||
assert.deepEqual(calls[0].params, ['member-1', 42, 'viewer', false, 'owner-member', 'billing@example.com', 'owner']);
|
|
||||||
assert.match(calls[0].text, /UPDATE workspace_members/);
|
|
||||||
assert.match(calls[0].text, /role <> 'owner'/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('updateWorkspaceMemberRole returns null when no non-owner member matches', async () => {
|
|
||||||
mockDb({
|
|
||||||
rowCount: 0,
|
|
||||||
rows: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
const member = await updateWorkspaceMemberRole({
|
|
||||||
memberId: 'owner-member',
|
|
||||||
workspaceId: 42,
|
|
||||||
role: 'viewer',
|
|
||||||
requesterMemberId: 'owner-member',
|
|
||||||
requesterIsBillingOwner: false,
|
|
||||||
requesterRole: 'owner',
|
|
||||||
billingEmail: 'billing@example.com',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(member, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('updateWorkspaceMemberRole lets the billing owner change another owner role', async () => {
|
|
||||||
const { calls } = mockDb({
|
|
||||||
rowCount: 1,
|
|
||||||
rows: [
|
|
||||||
{
|
|
||||||
id: 'other-owner',
|
|
||||||
workspace_id: 42,
|
|
||||||
user_id: 'user-2',
|
|
||||||
invite_email: 'other@example.com',
|
|
||||||
name: 'Other Owner',
|
|
||||||
role: 'assistant',
|
|
||||||
accepted_at: '2026-04-14T00:00:00.000Z',
|
|
||||||
created_at: '2026-04-14T00:00:00.000Z',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const member = await updateWorkspaceMemberRole({
|
|
||||||
memberId: 'other-owner',
|
|
||||||
workspaceId: 42,
|
|
||||||
role: 'assistant',
|
|
||||||
requesterMemberId: 'billing-owner',
|
|
||||||
requesterIsBillingOwner: true,
|
|
||||||
requesterRole: 'owner',
|
|
||||||
billingEmail: 'billing@example.com',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(member?.role, 'assistant');
|
|
||||||
assert.deepEqual(calls[0].params, ['other-owner', 42, 'assistant', true, 'billing-owner', 'billing@example.com', 'owner']);
|
|
||||||
assert.match(calls[0].text, /id <> \$5/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('updateWorkspaceMemberRole does not let the billing owner change their own owner role', async () => {
|
|
||||||
mockDb({
|
|
||||||
rowCount: 0,
|
|
||||||
rows: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
const member = await updateWorkspaceMemberRole({
|
|
||||||
memberId: 'billing-owner',
|
|
||||||
workspaceId: 42,
|
|
||||||
role: 'assistant',
|
|
||||||
requesterMemberId: 'billing-owner',
|
|
||||||
requesterIsBillingOwner: true,
|
|
||||||
requesterRole: 'owner',
|
|
||||||
billingEmail: 'billing@example.com',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(member, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('updateWorkspaceMemberRole lets a non-billing owner change another non-billing owner role', async () => {
|
|
||||||
const { calls } = mockDb({
|
|
||||||
rowCount: 1,
|
|
||||||
rows: [
|
|
||||||
{
|
|
||||||
id: 'other-owner',
|
|
||||||
workspace_id: 42,
|
|
||||||
user_id: 'user-2',
|
|
||||||
invite_email: 'other@example.com',
|
|
||||||
name: 'Other Owner',
|
|
||||||
role: 'assistant',
|
|
||||||
accepted_at: '2026-04-14T00:00:00.000Z',
|
|
||||||
created_at: '2026-04-14T00:00:00.000Z',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const member = await updateWorkspaceMemberRole({
|
|
||||||
memberId: 'other-owner',
|
|
||||||
workspaceId: 42,
|
|
||||||
role: 'assistant',
|
|
||||||
requesterMemberId: 'non-billing-owner',
|
|
||||||
requesterIsBillingOwner: false,
|
|
||||||
requesterRole: 'owner',
|
|
||||||
billingEmail: 'billing@example.com',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(member?.role, 'assistant');
|
|
||||||
assert.deepEqual(calls[0].params, ['other-owner', 42, 'assistant', false, 'non-billing-owner', 'billing@example.com', 'owner']);
|
|
||||||
assert.match(calls[0].text, /LOWER\(BTRIM\(COALESCE\(invite_email, email\)\)\) <> LOWER\(BTRIM\(\$6\)\)/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('updateWorkspaceMemberRole does not let a non-billing owner change the billing owner role', async () => {
|
|
||||||
mockDb({
|
|
||||||
rowCount: 0,
|
|
||||||
rows: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
const member = await updateWorkspaceMemberRole({
|
|
||||||
memberId: 'billing-owner',
|
|
||||||
workspaceId: 42,
|
|
||||||
role: 'assistant',
|
|
||||||
requesterMemberId: 'non-billing-owner',
|
|
||||||
requesterIsBillingOwner: false,
|
|
||||||
requesterRole: 'owner',
|
|
||||||
billingEmail: 'billing@example.com',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(member, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('updateWorkspaceMemberRole lets the billing owner promote a non-owner to owner', async () => {
|
|
||||||
const { calls } = mockDb({
|
|
||||||
rowCount: 1,
|
|
||||||
rows: [
|
|
||||||
{
|
|
||||||
id: 'member-1',
|
|
||||||
workspace_id: 42,
|
|
||||||
user_id: 'user-2',
|
|
||||||
invite_email: 'helper@example.com',
|
|
||||||
name: 'Helper',
|
|
||||||
role: 'owner',
|
|
||||||
accepted_at: '2026-04-14T00:00:00.000Z',
|
|
||||||
created_at: '2026-04-14T00:00:00.000Z',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const member = await updateWorkspaceMemberRole({
|
|
||||||
memberId: 'member-1',
|
|
||||||
workspaceId: 42,
|
|
||||||
role: 'owner',
|
|
||||||
requesterMemberId: 'billing-owner',
|
|
||||||
requesterIsBillingOwner: true,
|
|
||||||
requesterRole: 'owner',
|
|
||||||
billingEmail: 'billing@example.com',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(member?.role, 'owner');
|
|
||||||
assert.deepEqual(calls[0].params, ['member-1', 42, 'owner', true, 'billing-owner', 'billing@example.com', 'owner']);
|
|
||||||
assert.match(calls[0].text, /\$3 <> 'owner'/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('updateWorkspaceMemberRole does not let a non-billing owner promote a member to owner', async () => {
|
|
||||||
mockDb({
|
|
||||||
rowCount: 0,
|
|
||||||
rows: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
const member = await updateWorkspaceMemberRole({
|
|
||||||
memberId: 'member-1',
|
|
||||||
workspaceId: 42,
|
|
||||||
role: 'owner',
|
|
||||||
requesterMemberId: 'non-billing-owner',
|
|
||||||
requesterIsBillingOwner: false,
|
|
||||||
requesterRole: 'owner',
|
|
||||||
billingEmail: 'billing@example.com',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(member, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('deleteWorkspaceMember removes non-owner members without billing owner access', async () => {
|
|
||||||
const { calls } = mockDb({
|
|
||||||
rowCount: 1,
|
|
||||||
rows: [{ id: 'member-1' }],
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleted = await deleteWorkspaceMember({
|
|
||||||
memberId: 'member-1',
|
|
||||||
workspaceId: 42,
|
|
||||||
requesterMemberId: 'owner-member',
|
|
||||||
requesterIsBillingOwner: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(deleted, true);
|
|
||||||
assert.deepEqual(calls[0].params, ['member-1', 42, false, 'owner-member']);
|
|
||||||
assert.match(calls[0].text, /role <> 'owner'/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('deleteWorkspaceMember lets the billing owner remove another owner', async () => {
|
|
||||||
const { calls } = mockDb({
|
|
||||||
rowCount: 1,
|
|
||||||
rows: [{ id: 'other-owner' }],
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleted = await deleteWorkspaceMember({
|
|
||||||
memberId: 'other-owner',
|
|
||||||
workspaceId: 42,
|
|
||||||
requesterMemberId: 'billing-owner',
|
|
||||||
requesterIsBillingOwner: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(deleted, true);
|
|
||||||
assert.deepEqual(calls[0].params, ['other-owner', 42, true, 'billing-owner']);
|
|
||||||
assert.match(calls[0].text, /id <> \$4/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('deleteWorkspaceMember does not let the billing owner remove their own owner membership', async () => {
|
|
||||||
mockDb({
|
|
||||||
rowCount: 0,
|
|
||||||
rows: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleted = await deleteWorkspaceMember({
|
|
||||||
memberId: 'billing-owner',
|
|
||||||
workspaceId: 42,
|
|
||||||
requesterMemberId: 'billing-owner',
|
|
||||||
requesterIsBillingOwner: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(deleted, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('getPlatformAdminSummary counts memorialized birds separately', async () => {
|
test('getPlatformAdminSummary counts memorialized birds separately', async () => {
|
||||||
const { calls } = mockDb({
|
const { calls } = mockDb({
|
||||||
rowCount: 1,
|
rowCount: 1,
|
||||||
@@ -531,10 +272,6 @@ test('getPlatformAdminSummary counts memorialized birds separately', async () =>
|
|||||||
rescue_birds: 5,
|
rescue_birds: 5,
|
||||||
pending_rescues: 1,
|
pending_rescues: 1,
|
||||||
daily_users: 2,
|
daily_users: 2,
|
||||||
household_basic_subscriptions: 2,
|
|
||||||
household_plus_subscriptions: 1,
|
|
||||||
household_macaw_subscriptions: 0,
|
|
||||||
household_hyacinth_macaw_subscriptions: 1,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -547,7 +284,4 @@ test('getPlatformAdminSummary counts memorialized birds separately', async () =>
|
|||||||
assert.match(calls[0].text, /memorialized_at IS NOT NULL/);
|
assert.match(calls[0].text, /memorialized_at IS NOT NULL/);
|
||||||
assert.match(calls[0].text, /rescue_birds/);
|
assert.match(calls[0].text, /rescue_birds/);
|
||||||
assert.match(calls[0].text, /workspaces\.workspace_type = 'rescue'/);
|
assert.match(calls[0].text, /workspaces\.workspace_type = 'rescue'/);
|
||||||
assert.equal(summary?.household_basic_subscriptions, 2);
|
|
||||||
assert.match(calls[0].text, /billing_plan = 'household_basic'/);
|
|
||||||
assert.match(calls[0].text, /subscription_status IN \('active', 'trialing'\)/);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -364,81 +364,19 @@ export const upsertWorkspaceMember = async ({
|
|||||||
return result.rows[0] ?? null;
|
return result.rows[0] ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteWorkspaceMember = async ({
|
export const deleteWorkspaceMember = async (memberId: string, workspaceId: number) => {
|
||||||
memberId,
|
|
||||||
workspaceId,
|
|
||||||
requesterMemberId,
|
|
||||||
requesterIsBillingOwner,
|
|
||||||
}: {
|
|
||||||
memberId: string;
|
|
||||||
workspaceId: number;
|
|
||||||
requesterMemberId: string;
|
|
||||||
requesterIsBillingOwner: boolean;
|
|
||||||
}) => {
|
|
||||||
const result = await db.query<{ id: string }>(
|
const result = await db.query<{ id: string }>(
|
||||||
`DELETE FROM workspace_members
|
`DELETE FROM workspace_members
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND workspace_id = $2
|
AND workspace_id = $2
|
||||||
AND (
|
AND role <> 'owner'
|
||||||
role <> 'owner'
|
|
||||||
OR (
|
|
||||||
$3 = TRUE
|
|
||||||
AND id <> $4
|
|
||||||
)
|
|
||||||
)
|
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[memberId, workspaceId, requesterIsBillingOwner, requesterMemberId],
|
[memberId, workspaceId],
|
||||||
);
|
);
|
||||||
|
|
||||||
return Boolean(result.rowCount);
|
return Boolean(result.rowCount);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateWorkspaceMemberRole = async ({
|
|
||||||
memberId,
|
|
||||||
workspaceId,
|
|
||||||
role,
|
|
||||||
requesterMemberId,
|
|
||||||
requesterIsBillingOwner,
|
|
||||||
requesterRole,
|
|
||||||
billingEmail,
|
|
||||||
}: {
|
|
||||||
memberId: string;
|
|
||||||
workspaceId: number;
|
|
||||||
role: WorkspaceMemberRow['role'];
|
|
||||||
requesterMemberId: string;
|
|
||||||
requesterIsBillingOwner: boolean;
|
|
||||||
requesterRole: WorkspaceMemberRow['role'];
|
|
||||||
billingEmail: string;
|
|
||||||
}) => {
|
|
||||||
const result = await db.query<WorkspaceMemberRow>(
|
|
||||||
`UPDATE workspace_members
|
|
||||||
SET role = $3
|
|
||||||
WHERE id = $1
|
|
||||||
AND workspace_id = $2
|
|
||||||
AND (
|
|
||||||
$3 <> 'owner'
|
|
||||||
OR $4 = TRUE
|
|
||||||
)
|
|
||||||
AND (
|
|
||||||
role <> 'owner'
|
|
||||||
OR (
|
|
||||||
id <> $5
|
|
||||||
AND (
|
|
||||||
$4 = TRUE
|
|
||||||
OR (
|
|
||||||
$7 = 'owner'
|
|
||||||
AND LOWER(BTRIM(COALESCE(invite_email, email))) <> LOWER(BTRIM($6))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
RETURNING id, workspace_id, user_id, COALESCE(invite_email, email) AS invite_email, name, role, accepted_at::text, created_at`,
|
|
||||||
[memberId, workspaceId, role, requesterIsBillingOwner, requesterMemberId, billingEmail, requesterRole],
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.rows[0] ?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const listRescueWorkspacesForAdmin = async () => {
|
export const listRescueWorkspacesForAdmin = async () => {
|
||||||
const result = await db.query<
|
const result = await db.query<
|
||||||
WorkspaceRow & {
|
WorkspaceRow & {
|
||||||
@@ -605,10 +543,6 @@ export const getPlatformAdminSummary = async () => {
|
|||||||
rescue_birds: number;
|
rescue_birds: number;
|
||||||
pending_rescues: number;
|
pending_rescues: number;
|
||||||
daily_users: number;
|
daily_users: number;
|
||||||
household_basic_subscriptions: number;
|
|
||||||
household_plus_subscriptions: number;
|
|
||||||
household_macaw_subscriptions: number;
|
|
||||||
household_hyacinth_macaw_subscriptions: number;
|
|
||||||
}>(
|
}>(
|
||||||
`SELECT
|
`SELECT
|
||||||
(SELECT COUNT(*)::int FROM birds) AS total_birds,
|
(SELECT COUNT(*)::int FROM birds) AS total_birds,
|
||||||
@@ -618,11 +552,7 @@ export const getPlatformAdminSummary = async () => {
|
|||||||
(SELECT COUNT(*)::int FROM workspaces WHERE workspace_type = 'rescue') AS rescue_workspaces,
|
(SELECT COUNT(*)::int FROM workspaces WHERE workspace_type = 'rescue') AS rescue_workspaces,
|
||||||
(SELECT COUNT(*)::int FROM birds INNER JOIN workspaces ON workspaces.id = birds.workspace_id WHERE workspaces.workspace_type = 'rescue') AS rescue_birds,
|
(SELECT COUNT(*)::int FROM birds INNER JOIN workspaces ON workspaces.id = birds.workspace_id WHERE workspaces.workspace_type = 'rescue') AS rescue_birds,
|
||||||
(SELECT COUNT(*)::int FROM workspaces WHERE workspace_type = 'rescue' AND rescue_verification_status = 'pending') AS pending_rescues,
|
(SELECT COUNT(*)::int FROM workspaces WHERE workspace_type = 'rescue' AND rescue_verification_status = 'pending') AS pending_rescues,
|
||||||
(SELECT COUNT(DISTINCT user_id)::int FROM auth_sessions WHERE created_at >= CURRENT_DATE) AS daily_users,
|
(SELECT COUNT(DISTINCT user_id)::int FROM auth_sessions WHERE created_at >= CURRENT_DATE) AS daily_users`,
|
||||||
(SELECT COUNT(*)::int FROM workspaces WHERE billing_plan = 'household_basic' AND subscription_status IN ('active', 'trialing')) AS household_basic_subscriptions,
|
|
||||||
(SELECT COUNT(*)::int FROM workspaces WHERE billing_plan = 'household_plus' AND subscription_status IN ('active', 'trialing')) AS household_plus_subscriptions,
|
|
||||||
(SELECT COUNT(*)::int FROM workspaces WHERE billing_plan = 'household_macaw' AND subscription_status IN ('active', 'trialing')) AS household_macaw_subscriptions,
|
|
||||||
(SELECT COUNT(*)::int FROM workspaces WHERE billing_plan = 'household_hyacinth_macaw' AND subscription_status IN ('active', 'trialing')) AS household_hyacinth_macaw_subscriptions`,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return result.rows[0];
|
return result.rows[0];
|
||||||
|
|||||||
+1
-123
@@ -6,45 +6,16 @@ export type SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled'
|
|||||||
export type RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejected';
|
export type RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejected';
|
||||||
export type ProviderKey = 'google' | 'microsoft' | 'apple';
|
export type ProviderKey = 'google' | 'microsoft' | 'apple';
|
||||||
export type IntegrationTokenScope = 'read_only' | 'read_write';
|
export type IntegrationTokenScope = 'read_only' | 'read_write';
|
||||||
export type BirdGender = 'unknown' | 'male' | 'female' | 'male_dna' | 'female_dna';
|
export type BirdGender = 'unknown' | 'male' | 'female';
|
||||||
|
|
||||||
export type UserRow = {
|
export type UserRow = {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
password_hash: string | null;
|
password_hash: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
education_opt_out?: boolean;
|
|
||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DailyEducationQuestion = {
|
|
||||||
prompt: string;
|
|
||||||
options: string[];
|
|
||||||
correctAnswerIndex: number;
|
|
||||||
explanation: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DailyEducationRow = {
|
|
||||||
id: string;
|
|
||||||
publish_date: string;
|
|
||||||
fact: string;
|
|
||||||
quiz_questions: DailyEducationQuestion[];
|
|
||||||
created_by_user_id: string | null;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type EducationQuestionRow = {
|
|
||||||
id: string;
|
|
||||||
prompt: string;
|
|
||||||
options: string[];
|
|
||||||
correct_answer_index: number;
|
|
||||||
explanation: string | null;
|
|
||||||
created_by_user_id: string | null;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WorkspaceRow = {
|
export type WorkspaceRow = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -130,12 +101,6 @@ export type BirdRow = {
|
|||||||
motivators: string | null;
|
motivators: string | null;
|
||||||
demotivators: string | null;
|
demotivators: string | null;
|
||||||
favorite_snack: string | null;
|
favorite_snack: string | null;
|
||||||
location_label: string | null;
|
|
||||||
location_details: Record<string, unknown> | null;
|
|
||||||
vet_clinic_name: string | null;
|
|
||||||
vet_clinic_address: string | null;
|
|
||||||
vet_account_number: string | null;
|
|
||||||
vet_doctor_name: string | null;
|
|
||||||
gender: BirdGender;
|
gender: BirdGender;
|
||||||
date_of_birth: string | null;
|
date_of_birth: string | null;
|
||||||
gotcha_day: string | null;
|
gotcha_day: string | null;
|
||||||
@@ -193,38 +158,6 @@ export type PendingBirdTransferRow = {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BirdTransferCodeRow = {
|
|
||||||
id: string;
|
|
||||||
code: string;
|
|
||||||
bird_id: string;
|
|
||||||
source_workspace_id: number;
|
|
||||||
requested_by_user_id: string;
|
|
||||||
completed_at: string | null;
|
|
||||||
completed_workspace_id: number | null;
|
|
||||||
revoked_at: string | null;
|
|
||||||
created_at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BirdTimelineEventType = 'profile_created' | 'transferred' | 'location_updated' | 'owner_changed' | 'manual_note';
|
|
||||||
|
|
||||||
export type BirdTimelineEventRow = {
|
|
||||||
id: string;
|
|
||||||
bird_id: string;
|
|
||||||
event_type: BirdTimelineEventType;
|
|
||||||
from_workspace_id: number | null;
|
|
||||||
to_workspace_id: number | null;
|
|
||||||
from_workspace_name: string | null;
|
|
||||||
to_workspace_name: string | null;
|
|
||||||
from_owner_email: string | null;
|
|
||||||
to_owner_email: string | null;
|
|
||||||
location_label: string | null;
|
|
||||||
location_details: Record<string, unknown> | null;
|
|
||||||
note: string | null;
|
|
||||||
event_date: string;
|
|
||||||
created_by_user_id: string | null;
|
|
||||||
created_at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WeightRow = {
|
export type WeightRow = {
|
||||||
id: string;
|
id: string;
|
||||||
bird_id: string;
|
bird_id: string;
|
||||||
@@ -253,7 +186,6 @@ export type MedicationRow = {
|
|||||||
start_date: string;
|
start_date: string;
|
||||||
end_date: string | null;
|
end_date: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
reminders_enabled: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MedicationDoseScheduleItem = {
|
export type MedicationDoseScheduleItem = {
|
||||||
@@ -262,33 +194,6 @@ export type MedicationDoseScheduleItem = {
|
|||||||
time: string;
|
time: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MedicationReminderCandidateRow = BirdRow & {
|
|
||||||
workspace_name: string;
|
|
||||||
medication_id: string;
|
|
||||||
medication_name: string;
|
|
||||||
dosage: string;
|
|
||||||
frequency: string;
|
|
||||||
dose_schedule: MedicationDoseScheduleItem[];
|
|
||||||
route: string | null;
|
|
||||||
medication_start_date: string;
|
|
||||||
medication_end_date: string | null;
|
|
||||||
medication_notes: string | null;
|
|
||||||
scheduled_on: string;
|
|
||||||
administration_slot: string;
|
|
||||||
administration_label: string;
|
|
||||||
administration_time: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MedicationReminderDeliveryRow = {
|
|
||||||
id: string;
|
|
||||||
medication_id: string;
|
|
||||||
bird_id: string;
|
|
||||||
workspace_id: number;
|
|
||||||
scheduled_on: string;
|
|
||||||
administration_slot: string;
|
|
||||||
delivered_at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MedicationAdministrationRow = {
|
export type MedicationAdministrationRow = {
|
||||||
id: string;
|
id: string;
|
||||||
medication_id: string;
|
medication_id: string;
|
||||||
@@ -301,33 +206,6 @@ export type MedicationAdministrationRow = {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FlockNoteRow = {
|
|
||||||
id: string;
|
|
||||||
workspace_id: number;
|
|
||||||
bird_id: string | null;
|
|
||||||
bird_name: string | null;
|
|
||||||
title: string;
|
|
||||||
body: string;
|
|
||||||
created_by_user_id: string | null;
|
|
||||||
created_by_name: string | null;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AuditLogEntryRow = {
|
|
||||||
id: string;
|
|
||||||
workspace_id: number;
|
|
||||||
user_id: string | null;
|
|
||||||
actor_name: string | null;
|
|
||||||
actor_email: string | null;
|
|
||||||
action: string;
|
|
||||||
entity_type: string;
|
|
||||||
entity_id: string | null;
|
|
||||||
entity_name: string | null;
|
|
||||||
details: Record<string, unknown>;
|
|
||||||
created_at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AuthContext = {
|
export type AuthContext = {
|
||||||
user: UserRow;
|
user: UserRow;
|
||||||
session: AuthSessionRow;
|
session: AuthSessionRow;
|
||||||
|
|||||||
+1
-82
@@ -1,42 +1,17 @@
|
|||||||
import { runWeightReminders } from './reminders/weightReminders.js';
|
|
||||||
import { weightReminderQueue, weightReminderQueueName, startWeightReminderScheduler } from './queues/weightReminderQueue.js';
|
|
||||||
import { Worker } from 'bullmq';
|
import { Worker } from 'bullmq';
|
||||||
|
|
||||||
import { ensureSchema } from './db/schema.js';
|
import { ensureSchema } from './db/schema.js';
|
||||||
import { db } from './db/client.js';
|
import { db } from './db/client.js';
|
||||||
import {
|
import { runBirdMilestoneReminders, startBirdMilestoneReminderScheduler } from './app.js';
|
||||||
sendWeightReminderNotification,
|
|
||||||
runBirdMilestoneReminders,
|
|
||||||
runMedicationReminders,
|
|
||||||
startBirdMilestoneReminderScheduler,
|
|
||||||
startMedicationReminderScheduler,
|
|
||||||
} from './app.js';
|
|
||||||
import {
|
|
||||||
adoptionReportQueueName,
|
|
||||||
closeAdoptionReportQueue,
|
|
||||||
type AdoptionReportJobData,
|
|
||||||
type AdoptionReportJobResult,
|
|
||||||
} from './queues/adoptionReportQueue.js';
|
|
||||||
import {
|
import {
|
||||||
birdMilestoneReminderQueueName,
|
birdMilestoneReminderQueueName,
|
||||||
closeBirdMilestoneReminderQueue,
|
closeBirdMilestoneReminderQueue,
|
||||||
type BirdMilestoneReminderJobData,
|
type BirdMilestoneReminderJobData,
|
||||||
type BirdMilestoneReminderJobResult,
|
type BirdMilestoneReminderJobResult,
|
||||||
} from './queues/birdMilestoneReminderQueue.js';
|
} from './queues/birdMilestoneReminderQueue.js';
|
||||||
import {
|
|
||||||
closeMedicationReminderQueue,
|
|
||||||
medicationReminderQueueName,
|
|
||||||
type MedicationReminderJobData,
|
|
||||||
type MedicationReminderJobResult,
|
|
||||||
} from './queues/medicationReminderQueue.js';
|
|
||||||
import { redisConnection } from './queues/redisConnection.js';
|
import { redisConnection } from './queues/redisConnection.js';
|
||||||
import { renderAdoptionReportForBird } from './reports/adoptionReportJob.js';
|
|
||||||
|
|
||||||
let stopWeightReminderScheduler: (() => void) | undefined;
|
|
||||||
let weightReminderWorker: Worker | null = null;
|
|
||||||
let birdMilestoneWorker: Worker<BirdMilestoneReminderJobData, BirdMilestoneReminderJobResult> | null = null;
|
let birdMilestoneWorker: Worker<BirdMilestoneReminderJobData, BirdMilestoneReminderJobResult> | null = null;
|
||||||
let medicationReminderWorker: Worker<MedicationReminderJobData, MedicationReminderJobResult> | null = null;
|
|
||||||
let adoptionReportWorker: Worker<AdoptionReportJobData, AdoptionReportJobResult> | null = null;
|
|
||||||
|
|
||||||
const startWorker = async () => {
|
const startWorker = async () => {
|
||||||
await ensureSchema();
|
await ensureSchema();
|
||||||
@@ -60,70 +35,14 @@ const startWorker = async () => {
|
|||||||
console.error(`Bird milestone reminder job failed: id=${job?.id ?? 'unknown'}`, error);
|
console.error(`Bird milestone reminder job failed: id=${job?.id ?? 'unknown'}`, error);
|
||||||
});
|
});
|
||||||
|
|
||||||
medicationReminderWorker = new Worker<MedicationReminderJobData, MedicationReminderJobResult>(
|
|
||||||
medicationReminderQueueName,
|
|
||||||
async (job) => {
|
|
||||||
const result = await runMedicationReminders(job.data.runDate, job.data.currentTime);
|
|
||||||
console.log(
|
|
||||||
`Medication reminder job completed for ${result.runDate} ${result.currentTime}: checked=${result.checked}, sent=${result.sent}, skipped=${result.skipped}, failed=${result.failed}`,
|
|
||||||
);
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
connection: redisConnection,
|
|
||||||
concurrency: 1,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
medicationReminderWorker.on('failed', (job, error) => {
|
|
||||||
console.error(`Medication reminder job failed: id=${job?.id ?? 'unknown'}`, error);
|
|
||||||
});
|
|
||||||
|
|
||||||
adoptionReportWorker = new Worker<AdoptionReportJobData, AdoptionReportJobResult>(
|
|
||||||
adoptionReportQueueName,
|
|
||||||
async (job) => {
|
|
||||||
const pdf = await renderAdoptionReportForBird(job.data);
|
|
||||||
console.log(`Adoption report job completed: id=${job.id ?? 'unknown'}, birdId=${job.data.birdId}, bytes=${pdf.length}`);
|
|
||||||
return {
|
|
||||||
pdfBase64: pdf.toString('base64'),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
{
|
|
||||||
connection: redisConnection,
|
|
||||||
concurrency: 1,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
adoptionReportWorker.on('failed', (job, error) => {
|
|
||||||
console.error(`Adoption report job failed: id=${job?.id ?? 'unknown'}, birdId=${job?.data.birdId ?? 'unknown'}`, error);
|
|
||||||
});
|
|
||||||
|
|
||||||
weightReminderWorker = new Worker(weightReminderQueueName, async () => {
|
|
||||||
if (process.env.WEIGHT_REMINDERS_ENABLED === 'false') return;
|
|
||||||
const result = await runWeightReminders(sendWeightReminderNotification);
|
|
||||||
console.log('Weight reminder job completed', result);
|
|
||||||
return result;
|
|
||||||
}, { connection: redisConnection, concurrency: 1 });
|
|
||||||
weightReminderWorker.on('failed', (job, error) => {
|
|
||||||
console.error(`Weight reminder job failed: id=${job?.id ?? 'unknown'}`, error);
|
|
||||||
});
|
|
||||||
stopWeightReminderScheduler = await startWeightReminderScheduler();
|
|
||||||
startBirdMilestoneReminderScheduler();
|
startBirdMilestoneReminderScheduler();
|
||||||
startMedicationReminderScheduler();
|
|
||||||
console.log('FlockPal worker started.');
|
console.log('FlockPal worker started.');
|
||||||
};
|
};
|
||||||
|
|
||||||
const shutdown = async (signal: string) => {
|
const shutdown = async (signal: string) => {
|
||||||
console.log(`FlockPal worker received ${signal}; shutting down.`);
|
console.log(`FlockPal worker received ${signal}; shutting down.`);
|
||||||
stopWeightReminderScheduler?.();
|
|
||||||
await weightReminderWorker?.close();
|
|
||||||
await weightReminderQueue.close();
|
|
||||||
await birdMilestoneWorker?.close();
|
await birdMilestoneWorker?.close();
|
||||||
await medicationReminderWorker?.close();
|
|
||||||
await adoptionReportWorker?.close();
|
|
||||||
await closeBirdMilestoneReminderQueue();
|
await closeBirdMilestoneReminderQueue();
|
||||||
await closeMedicationReminderQueue();
|
|
||||||
await closeAdoptionReportQueue();
|
|
||||||
await db.close();
|
await db.close();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: flockpal_test
|
|
||||||
POSTGRES_PASSWORD: isolated_test_password
|
|
||||||
POSTGRES_DB: flockpal_weight_test
|
|
||||||
ports:
|
|
||||||
- '127.0.0.1:25432:5432'
|
|
||||||
tmpfs:
|
|
||||||
- /var/lib/postgresql/data
|
|
||||||
healthcheck:
|
|
||||||
test: ['CMD-SHELL', 'pg_isready -U flockpal_test -d flockpal_weight_test']
|
|
||||||
interval: 1s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 30
|
|
||||||
redis:
|
|
||||||
image: redis:7-alpine
|
|
||||||
command: ['redis-server', '--save', '', '--appendonly', 'no']
|
|
||||||
ports:
|
|
||||||
- '127.0.0.1:26379:6379'
|
|
||||||
mail:
|
|
||||||
image: axllent/mailpit:latest
|
|
||||||
ports:
|
|
||||||
- '127.0.0.1:21025:1025'
|
|
||||||
- '127.0.0.1:28025:8025'
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
// Run only against disposable local Postgres, Redis, and Mailpit services.
|
|
||||||
// See docs/WEIGHT_REMINDER_TESTING.md for the isolated Compose setup.
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { readFile } from 'node:fs/promises';
|
|
||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import { spawn } from 'node:child_process';
|
|
||||||
import { once } from 'node:events';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
Object.assign(process.env, {
|
|
||||||
POSTGRES_HOST: '127.0.0.1', POSTGRES_PORT: '25432', POSTGRES_DB: 'flockpal_weight_test',
|
|
||||||
POSTGRES_USER: 'flockpal_test', POSTGRES_PASSWORD: 'isolated_test_password',
|
|
||||||
REDIS_URL: 'redis://127.0.0.1:26379',
|
|
||||||
SMTP_HOST: '127.0.0.1', SMTP_PORT: '21025', SMTP_SECURE: 'false',
|
|
||||||
SMTP_USER: '', SMTP_PASS: '', SMTP_FROM_EMAIL: 'reminders@flockpal.test', SMTP_FROM_NAME: 'FlockPal Test',
|
|
||||||
FRONTEND_URL: 'http://flockpal.test', MILESTONE_REMINDERS_ENABLED: 'false',
|
|
||||||
MEDICATION_REMINDERS_ENABLED: 'false', WEIGHT_REMINDERS_ENABLED: 'true',
|
|
||||||
MILESTONE_REMINDER_TIME_ZONE: 'America/New_York',
|
|
||||||
});
|
|
||||||
const { db } = await import('../dist/db/client.js');
|
|
||||||
const { ensureSchema } = await import('../dist/db/schema.js');
|
|
||||||
const { getReminderDate } = await import('../dist/reminders/reminderDate.js');
|
|
||||||
const { listDueWeightReminders, claimWeightReminder, finishWeightReminder } = await import('../dist/reminders/weightReminders.js');
|
|
||||||
const { weightReminderQueue, startWeightReminderScheduler, enqueueDailyWeightReminder } = await import('../dist/queues/weightReminderQueue.js');
|
|
||||||
const workers = [];
|
|
||||||
let logs = '';
|
|
||||||
const poll = async (check, label) => {
|
|
||||||
const deadline = Date.now() + 30_000;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
if (await check()) return;
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
}
|
|
||||||
throw new Error(`Timed out: ${label}\n${logs}`);
|
|
||||||
};
|
|
||||||
const messages = async () => (await (await fetch('http://127.0.0.1:28025/api/v1/messages')).json()).messages;
|
|
||||||
const startWorker = () => {
|
|
||||||
const child = spawn(process.execPath, [fileURLToPath(new URL('../dist/worker.js', import.meta.url))], {
|
|
||||||
cwd: '/tmp', env: process.env, stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
});
|
|
||||||
child.stdout.on('data', data => { logs += data; });
|
|
||||||
child.stderr.on('data', data => { logs += data; });
|
|
||||||
workers.push(child);
|
|
||||||
};
|
|
||||||
const stopWorkers = async () => {
|
|
||||||
const exits = workers.map(child => once(child, 'exit'));
|
|
||||||
for (const child of workers) child.kill('SIGTERM');
|
|
||||||
await Promise.all(exits);
|
|
||||||
workers.length = 0;
|
|
||||||
};
|
|
||||||
const drain = async () => {
|
|
||||||
await poll(async () => {
|
|
||||||
const counts = await weightReminderQueue.getJobCounts('active', 'waiting');
|
|
||||||
return !counts.active && !counts.waiting;
|
|
||||||
}, 'queue drain');
|
|
||||||
};
|
|
||||||
const runJob = async () => {
|
|
||||||
const job = await weightReminderQueue.add('integration-check', {});
|
|
||||||
await poll(async () => ['completed', 'failed'].includes(await job.getState()), 'manual job');
|
|
||||||
assert.equal(await job.getState(), 'completed');
|
|
||||||
return (await weightReminderQueue.getJob(job.id)).returnvalue;
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
await ensureSchema();
|
|
||||||
await ensureSchema();
|
|
||||||
assert.equal((await db.query('SELECT COUNT(*)::int AS count FROM birds')).rows[0].count, 0, 'Use a fresh disposable database');
|
|
||||||
assert.equal((await messages()).length, 0, 'Use a fresh email catcher');
|
|
||||||
await db.query("INSERT INTO workspaces (id, name) VALUES (101, 'Test Flock'), (102, 'Other Flock')");
|
|
||||||
const ids = {};
|
|
||||||
for (const [role, accepted] of [['owner', true], ['assistant', true], ['caregiver', true], ['viewer', true], ['pending', false]]) {
|
|
||||||
const id = randomUUID(); ids[role] = id;
|
|
||||||
await db.query('INSERT INTO users (id, email, name) VALUES ($1, $2, $3)', [id, `${role}@example.test`, role]);
|
|
||||||
await db.query(`INSERT INTO workspace_members (workspace_id, user_id, invite_email, name, role, accepted_at)
|
|
||||||
VALUES (101, $1, $2, $3, $4, ${accepted ? 'CURRENT_TIMESTAMP' : 'NULL'})`, [id, `${role}@example.test`, role, role === 'pending' ? 'caregiver' : role]);
|
|
||||||
}
|
|
||||||
await db.query("INSERT INTO workspace_members (workspace_id, user_id, invite_email, name, role, accepted_at) VALUES (102, $1, 'owner@example.test', 'owner', 'owner', CURRENT_TIMESTAMP)", [ids.owner]);
|
|
||||||
const birds = {};
|
|
||||||
for (const [name, age, memorial, workspace] of [
|
|
||||||
['Kiwi & <Peep>', 72, false, 101], ['Boundary', 48, false, 101],
|
|
||||||
['No weights', null, false, 101], ['Recent', 47, false, 101],
|
|
||||||
['Memorial', 72, true, 101], ['Other bird', 72, false, 102],
|
|
||||||
]) {
|
|
||||||
const id = randomUUID(); birds[name] = id;
|
|
||||||
await db.query(`INSERT INTO birds (id, workspace_id, name, species, created_at, memorialized_at)
|
|
||||||
VALUES ($1, $2, $3, 'Cockatiel', CURRENT_TIMESTAMP - INTERVAL '100 hours', ${memorial ? 'CURRENT_TIMESTAMP' : 'NULL'})`, [id, workspace, name]);
|
|
||||||
if (age !== null) await db.query("INSERT INTO weight_records (bird_id, weight_grams, recorded_on, created_at) VALUES ($1, 90, CURRENT_DATE - 3, CURRENT_TIMESTAMP - $2 * INTERVAL '1 hour')", [id, age]);
|
|
||||||
}
|
|
||||||
const portrait = await readFile(new URL('../assets/yoda.png', import.meta.url));
|
|
||||||
await db.query('UPDATE birds SET photo_data_url = $1 WHERE id = $2', [`data:image/png;base64,${portrait.toString('base64')}`, birds['Kiwi & <Peep>']]);
|
|
||||||
const due = await listDueWeightReminders();
|
|
||||||
assert.equal(due.length, 10);
|
|
||||||
assert.deepEqual(new Set(due.map(b => b.recipient)), new Set(['owner@example.test', 'assistant@example.test', 'caregiver@example.test']));
|
|
||||||
const candidate = due[0];
|
|
||||||
const tokens = [randomUUID(), randomUUID()];
|
|
||||||
const claims = await Promise.all(tokens.map(token => claimWeightReminder(candidate, token)));
|
|
||||||
assert.equal(claims.filter(Boolean).length, 1, 'Only one concurrent claim wins');
|
|
||||||
await finishWeightReminder(tokens[claims.indexOf(true)], false);
|
|
||||||
console.log('PASS: schema initialization is repeatable; eligibility, roles, and concurrent claims');
|
|
||||||
|
|
||||||
// Simulate the schedule present before upgrading to daily reminders.
|
|
||||||
await weightReminderQueue.upsertJobScheduler('weight-reminders', { every: 300_000 }, { name: 'check-overdue-weights', data: {} });
|
|
||||||
startWorker(); startWorker();
|
|
||||||
await poll(async () => (await messages()).length === 4, 'initial grouped emails');
|
|
||||||
await drain();
|
|
||||||
const initial = await messages();
|
|
||||||
const bodies = await Promise.all(initial.map(async message => (await fetch(`http://127.0.0.1:28025/api/v1/message/${message.ID}`)).json()));
|
|
||||||
for (const body of bodies) {
|
|
||||||
if (body.Subject === 'Weight reminders for Test Flock') {
|
|
||||||
for (const name of ['Kiwi & <Peep>', 'Boundary', 'No weights']) assert.ok(body.Text.includes(name));
|
|
||||||
assert.ok(body.HTML.includes('Kiwi & <Peep>'));
|
|
||||||
assert.ok(!body.Text.includes('Memorial'));
|
|
||||||
assert.ok(!body.Text.includes('Recent'));
|
|
||||||
assert.ok(!body.Text.includes('Other bird'));
|
|
||||||
} else {
|
|
||||||
assert.equal(body.Subject, 'Weight reminders for Other Flock');
|
|
||||||
assert.ok(body.Text.includes('Other bird'));
|
|
||||||
assert.ok(!body.Text.includes('Boundary'));
|
|
||||||
}
|
|
||||||
assert.equal(body.To.length, 1);
|
|
||||||
const inline = body.Inline ?? [];
|
|
||||||
assert.ok(inline.some(attachment => attachment.ContentID === 'flockpal-logo'));
|
|
||||||
assert.equal(inline.filter(attachment => attachment.ContentID.startsWith('weight-bird-')).length, body.Subject === 'Weight reminders for Test Flock' ? 3 : 1);
|
|
||||||
assert.ok(body.HTML.includes('Daily weight reminder'));
|
|
||||||
assert.ok(body.HTML.includes('Record their weights'));
|
|
||||||
}
|
|
||||||
assert.equal((await weightReminderQueue.getJobSchedulers()).length, 0);
|
|
||||||
await poll(async () => Boolean(await weightReminderQueue.getJob(`weight-reminders-${getReminderDate()}`)), 'startup daily scheduler');
|
|
||||||
const dailyJobs = await Promise.all([enqueueDailyWeightReminder(), enqueueDailyWeightReminder()]);
|
|
||||||
assert.equal(dailyJobs[0].id, dailyJobs[1].id);
|
|
||||||
await drain();
|
|
||||||
assert.equal(await weightReminderQueue.getGlobalConcurrency(), 1);
|
|
||||||
assert.equal((await runJob()).sent, 0);
|
|
||||||
assert.equal((await messages()).length, 4);
|
|
||||||
console.log('PASS: two real workers, one daily job, legacy schedule removed, grouped SMTP delivery, escaping, flock isolation, no immediate duplicates');
|
|
||||||
|
|
||||||
await db.query("UPDATE weight_reminder_deliveries SET delivered_at = CURRENT_TIMESTAMP");
|
|
||||||
assert.equal((await runJob()).sent, 0);
|
|
||||||
await db.query("UPDATE weight_reminder_deliveries SET delivered_at = ((CURRENT_TIMESTAMP AT TIME ZONE 'America/New_York')::date::timestamp AT TIME ZONE 'America/New_York') - INTERVAL '1 second'");
|
|
||||||
assert.equal((await runJob()).sent, 4);
|
|
||||||
assert.equal((await messages()).length, 8);
|
|
||||||
await db.query("INSERT INTO weight_records (bird_id, weight_grams, recorded_on) VALUES ($1, 91, CURRENT_DATE)", [birds.Boundary]);
|
|
||||||
await db.query("UPDATE weight_reminder_deliveries SET delivered_at = CURRENT_TIMESTAMP - INTERVAL '49 hours'");
|
|
||||||
assert.ok((await listDueWeightReminders()).every(b => b.bird_id !== birds.Boundary));
|
|
||||||
assert.equal((await runJob()).sent, 4);
|
|
||||||
console.log('PASS: no repeat on the same local date, daily repeat, fresh weight resets eligibility');
|
|
||||||
|
|
||||||
await stopWorkers();
|
|
||||||
await db.query("UPDATE weight_reminder_deliveries SET delivered_at = CURRENT_TIMESTAMP - INTERVAL '49 hours'");
|
|
||||||
const beforeFailures = (await messages()).length;
|
|
||||||
process.env.SMTP_HOST = '';
|
|
||||||
startWorker();
|
|
||||||
assert.equal((await runJob()).sent, 0);
|
|
||||||
assert.equal((await listDueWeightReminders()).length, 7);
|
|
||||||
await stopWorkers();
|
|
||||||
|
|
||||||
process.env.SMTP_HOST = '127.0.0.1';
|
|
||||||
process.env.SMTP_PORT = '21026'; // No SMTP listener: exercise a real refused connection.
|
|
||||||
startWorker();
|
|
||||||
const failed = await runJob();
|
|
||||||
assert.equal(failed.failed, 4);
|
|
||||||
assert.equal(failed.sent, 0);
|
|
||||||
assert.equal((await listDueWeightReminders()).length, 7);
|
|
||||||
assert.equal((await messages()).length, beforeFailures);
|
|
||||||
await stopWorkers();
|
|
||||||
|
|
||||||
process.env.SMTP_PORT = '21025';
|
|
||||||
startWorker();
|
|
||||||
await runJob();
|
|
||||||
await poll(async () => (await messages()).length === beforeFailures + 4, 'delivery after SMTP recovery');
|
|
||||||
await drain();
|
|
||||||
await stopWorkers();
|
|
||||||
console.log('PASS: missing SMTP, real SMTP connection failure, released claims, successful retry');
|
|
||||||
|
|
||||||
process.env.WEIGHT_REMINDERS_ENABLED = 'false';
|
|
||||||
await startWeightReminderScheduler();
|
|
||||||
assert.equal((await weightReminderQueue.getJobSchedulers()).length, 0);
|
|
||||||
console.log('PASS: graceful worker shutdown and disabled scheduler removal');
|
|
||||||
console.log('All Postgres / Redis / SMTP integration checks passed.');
|
|
||||||
} finally {
|
|
||||||
for (const child of workers) child.kill('SIGKILL');
|
|
||||||
await weightReminderQueue.close();
|
|
||||||
await db.close();
|
|
||||||
}
|
|
||||||
@@ -43,7 +43,6 @@ services:
|
|||||||
POSTGRES_USER: ${POSTGRES_USER:-flockpal}
|
POSTGRES_USER: ${POSTGRES_USER:-flockpal}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD for production}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD for production}
|
||||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
||||||
ADOPTION_REPORT_RENDER_TIMEOUT_MS: ${ADOPTION_REPORT_RENDER_TIMEOUT_MS:-45000}
|
|
||||||
IMAGE_STORAGE_PROVIDER: ${IMAGE_STORAGE_PROVIDER:-database}
|
IMAGE_STORAGE_PROVIDER: ${IMAGE_STORAGE_PROVIDER:-database}
|
||||||
S3_ENDPOINT: ${S3_ENDPOINT:-}
|
S3_ENDPOINT: ${S3_ENDPOINT:-}
|
||||||
S3_REGION: ${S3_REGION:-}
|
S3_REGION: ${S3_REGION:-}
|
||||||
@@ -55,13 +54,10 @@ services:
|
|||||||
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
|
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
|
||||||
FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production}
|
FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production}
|
||||||
BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production}
|
BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production}
|
||||||
MAPBOX_ACCESS_TOKEN: ${MAPBOX_ACCESS_TOKEN:-}
|
|
||||||
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
||||||
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
||||||
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
||||||
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
||||||
WEIGHT_REMINDERS_ENABLED: ${WEIGHT_REMINDERS_ENABLED:-true}
|
|
||||||
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
|
|
||||||
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
||||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
||||||
@@ -138,13 +134,10 @@ services:
|
|||||||
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
|
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
|
||||||
FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production}
|
FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production}
|
||||||
BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production}
|
BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production}
|
||||||
MAPBOX_ACCESS_TOKEN: ${MAPBOX_ACCESS_TOKEN:-}
|
|
||||||
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
||||||
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
||||||
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
||||||
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
||||||
WEIGHT_REMINDERS_ENABLED: ${WEIGHT_REMINDERS_ENABLED:-true}
|
|
||||||
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
|
|
||||||
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
||||||
SMTP_HOST: ${SMTP_HOST:-}
|
SMTP_HOST: ${SMTP_HOST:-}
|
||||||
SMTP_PORT: ${SMTP_PORT:-587}
|
SMTP_PORT: ${SMTP_PORT:-587}
|
||||||
|
|||||||
+1
-8
@@ -41,7 +41,6 @@ services:
|
|||||||
POSTGRES_USER: ${POSTGRES_USER:-flockpal}
|
POSTGRES_USER: ${POSTGRES_USER:-flockpal}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flockpal_dev_password}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flockpal_dev_password}
|
||||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
||||||
ADOPTION_REPORT_RENDER_TIMEOUT_MS: ${ADOPTION_REPORT_RENDER_TIMEOUT_MS:-45000}
|
|
||||||
IMAGE_STORAGE_PROVIDER: ${IMAGE_STORAGE_PROVIDER:-database}
|
IMAGE_STORAGE_PROVIDER: ${IMAGE_STORAGE_PROVIDER:-database}
|
||||||
S3_ENDPOINT: ${S3_ENDPOINT:-}
|
S3_ENDPOINT: ${S3_ENDPOINT:-}
|
||||||
S3_REGION: ${S3_REGION:-}
|
S3_REGION: ${S3_REGION:-}
|
||||||
@@ -53,13 +52,10 @@ services:
|
|||||||
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
|
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
|
||||||
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
|
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
|
||||||
BACKEND_URL: ${BACKEND_URL:-http://localhost:5000}
|
BACKEND_URL: ${BACKEND_URL:-http://localhost:5000}
|
||||||
MAPBOX_ACCESS_TOKEN: ${MAPBOX_ACCESS_TOKEN:-}
|
|
||||||
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
||||||
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
||||||
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
||||||
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
||||||
WEIGHT_REMINDERS_ENABLED: ${WEIGHT_REMINDERS_ENABLED:-true}
|
|
||||||
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
|
|
||||||
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
||||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
||||||
@@ -131,13 +127,10 @@ services:
|
|||||||
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
|
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
|
||||||
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
|
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
|
||||||
BACKEND_URL: ${BACKEND_URL:-http://localhost:5000}
|
BACKEND_URL: ${BACKEND_URL:-http://localhost:5000}
|
||||||
MAPBOX_ACCESS_TOKEN: ${MAPBOX_ACCESS_TOKEN:-}
|
|
||||||
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
||||||
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
||||||
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
||||||
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
||||||
WEIGHT_REMINDERS_ENABLED: ${WEIGHT_REMINDERS_ENABLED:-true}
|
|
||||||
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
|
|
||||||
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
||||||
SMTP_HOST: ${SMTP_HOST:-}
|
SMTP_HOST: ${SMTP_HOST:-}
|
||||||
SMTP_PORT: ${SMTP_PORT:-587}
|
SMTP_PORT: ${SMTP_PORT:-587}
|
||||||
@@ -164,7 +157,7 @@ services:
|
|||||||
dockerfile: Dockerfile.dev
|
dockerfile: Dockerfile.dev
|
||||||
container_name: flockpal-frontend
|
container_name: flockpal-frontend
|
||||||
environment:
|
environment:
|
||||||
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-/api}
|
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-http://localhost:5000/api}
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
+4
-46
@@ -208,11 +208,7 @@ Role requirements are called out per endpoint below. If the signed-in member lac
|
|||||||
"name": "Kiwi",
|
"name": "Kiwi",
|
||||||
"tagId": "FP-001",
|
"tagId": "FP-001",
|
||||||
"species": "Cockatiel",
|
"species": "Cockatiel",
|
||||||
"vetClinicName": "Avian Care Center",
|
"gender": "female",
|
||||||
"vetClinicAddress": "123 Feather Lane, Raleigh, NC",
|
|
||||||
"vetAccountNumber": "FP-1001",
|
|
||||||
"vetDoctorName": "Dr. Rivera",
|
|
||||||
"gender": "female_dna",
|
|
||||||
"dateOfBirth": "2023-05-10",
|
"dateOfBirth": "2023-05-10",
|
||||||
"gotchaDay": "2023-08-21",
|
"gotchaDay": "2023-08-21",
|
||||||
"chartColor": "#cb3a35",
|
"chartColor": "#cb3a35",
|
||||||
@@ -299,7 +295,7 @@ Role requirements are called out per endpoint below. If the signed-in member lac
|
|||||||
- Dates use `YYYY-MM-DD`
|
- Dates use `YYYY-MM-DD`
|
||||||
- `workspaceType` is `standard` or `rescue`
|
- `workspaceType` is `standard` or `rescue`
|
||||||
- member `role` is `owner`, `assistant`, `caregiver`, or `viewer`
|
- member `role` is `owner`, `assistant`, `caregiver`, or `viewer`
|
||||||
- bird `gender` is `unknown`, `male`, `female`, `male_dna`, or `female_dna`; `male` and `female` indicate assumed sex
|
- bird `gender` is `unknown`, `male`, or `female`
|
||||||
- bird `chartColor` must be a `#RRGGBB` hex color
|
- bird `chartColor` must be a `#RRGGBB` hex color
|
||||||
- `photoDataUrl` must be a base64 `data:image/...` URL
|
- `photoDataUrl` must be a base64 `data:image/...` URL
|
||||||
- `weightGrams` must be a positive number up to `10000`
|
- `weightGrams` must be a positive number up to `10000`
|
||||||
@@ -797,11 +793,7 @@ Request body:
|
|||||||
"name": "Kiwi",
|
"name": "Kiwi",
|
||||||
"tagId": "FP-001",
|
"tagId": "FP-001",
|
||||||
"species": "Cockatiel",
|
"species": "Cockatiel",
|
||||||
"vetClinicName": "Avian Care Center",
|
"gender": "female",
|
||||||
"vetClinicAddress": "123 Feather Lane, Raleigh, NC",
|
|
||||||
"vetAccountNumber": "FP-1001",
|
|
||||||
"vetDoctorName": "Dr. Rivera",
|
|
||||||
"gender": "female_dna",
|
|
||||||
"dateOfBirth": "2023-05-10",
|
"dateOfBirth": "2023-05-10",
|
||||||
"gotchaDay": "2023-08-21",
|
"gotchaDay": "2023-08-21",
|
||||||
"chartColor": "#cb3a35",
|
"chartColor": "#cb3a35",
|
||||||
@@ -813,7 +805,7 @@ Request body:
|
|||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- `dateOfBirth`, `gotchaDay`, `photoDataUrl`, and veterinary info fields may be omitted or sent as empty strings
|
- `dateOfBirth`, `gotchaDay`, and `photoDataUrl` may be omitted or sent as empty strings
|
||||||
- `chartColor` defaults to `#cb3a35`
|
- `chartColor` defaults to `#cb3a35`
|
||||||
|
|
||||||
Response `201`:
|
Response `201`:
|
||||||
@@ -897,40 +889,6 @@ Possible errors:
|
|||||||
- `409` if that owner email owns more than one receiving flock
|
- `409` if that owner email owns more than one receiving flock
|
||||||
- `409` if the destination flock already has a bird using the same `tagId`
|
- `409` if the destination flock already has a bird using the same `tagId`
|
||||||
|
|
||||||
#### `POST /api/birds/:birdId/transfer-code`
|
|
||||||
|
|
||||||
Requires a browser session, write access, and role `owner` or `assistant`. Creates a unique transfer code for a bird. Creating a new open code for the same bird revokes earlier unused codes for that bird.
|
|
||||||
|
|
||||||
Response `201`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"transferCode": {
|
|
||||||
"code": "secure-code",
|
|
||||||
"bird": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `POST /api/bird-transfer-codes/:code/accept`
|
|
||||||
|
|
||||||
Requires a browser session, write access, and role `owner` or `assistant`. Accepts a transfer code into the signed-in user's active flock.
|
|
||||||
|
|
||||||
Response `200`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"bird": {},
|
|
||||||
"sourceWorkspaceName": "Previous Flock",
|
|
||||||
"workspace": {}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Possible errors:
|
|
||||||
|
|
||||||
- `404` if the code does not exist, was revoked, was already used, or the bird is no longer available
|
|
||||||
- `409` if the bird is already in the active flock or the active flock already has the same `tagId`
|
|
||||||
|
|
||||||
#### `DELETE /api/birds/:birdId`
|
#### `DELETE /api/birds/:birdId`
|
||||||
|
|
||||||
Requires auth with write access and role `owner`, `assistant`, or `caregiver`. Deletes a bird.
|
Requires auth with write access and role `owner`, `assistant`, or `caregiver`. Deletes a bird.
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
# Weight reminder tests
|
|
||||||
|
|
||||||
From the repository root, run the unit tests and builds:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm ci --prefix backend
|
|
||||||
npm ci --prefix frontend
|
|
||||||
npm test --prefix backend
|
|
||||||
npm run build --prefix backend
|
|
||||||
npm run build --prefix frontend
|
|
||||||
```
|
|
||||||
|
|
||||||
The integration test uses real Postgres 16, Redis 7, two worker processes, and
|
|
||||||
Mailpit to capture SMTP messages locally. It requires Docker and free localhost
|
|
||||||
ports 25432, 26379, 21025, and 28025. Port 21026 must remain unused for the SMTP
|
|
||||||
failure check. Test addresses use `.test`; Mailpit does not relay mail externally.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -p flockpal-weight-test -f backend/test-integration/compose.weight-reminders.yml up -d --wait
|
|
||||||
node backend/test-integration/weightReminders.mjs
|
|
||||||
docker compose -p flockpal-weight-test -f backend/test-integration/compose.weight-reminders.yml down --volumes
|
|
||||||
```
|
|
||||||
|
|
||||||
Always run the last command when finished, including after a failed test. Start
|
|
||||||
fresh containers for each run. Postgres data is disposable and held in tmpfs.
|
|
||||||
The test overrides database, Redis, and SMTP environment variables to use only
|
|
||||||
these local services. It does not use the application's configured SMTP account.
|
|
||||||
|
|
||||||
Coverage includes repeatable schema initialization, overdue and recent weights,
|
|
||||||
birds without weights, memorial exclusions, accepted care roles, concurrent
|
|
||||||
claims, one daily job across two workers, removal of the old five-minute schedule, grouped emails, embedded branding and portraits, HTML escaping, flock
|
|
||||||
isolation, daily repeats, fresh weight resets, SMTP failures and recovery,
|
|
||||||
worker shutdown, and disabling the scheduler. Repeat timing is tested by aging
|
|
||||||
delivery timestamps rather than waiting two days.
|
|
||||||
@@ -7,7 +7,6 @@ RUN npm ci
|
|||||||
COPY tsconfig*.json ./
|
COPY tsconfig*.json ./
|
||||||
COPY vite.config.ts ./
|
COPY vite.config.ts ./
|
||||||
COPY index.html ./
|
COPY index.html ./
|
||||||
COPY public ./public
|
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ RUN npm install
|
|||||||
COPY tsconfig*.json ./
|
COPY tsconfig*.json ./
|
||||||
COPY vite.config.ts ./
|
COPY vite.config.ts ./
|
||||||
COPY index.html ./
|
COPY index.html ./
|
||||||
COPY public ./public
|
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["npm", "run", "dev", "--", "--host"]
|
CMD ["npm", "run", "dev", "--", "--host"]
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 237 KiB |
+627
-2952
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 242 KiB |
+20
-1219
File diff suppressed because it is too large
Load Diff
@@ -5,11 +5,5 @@ export default defineConfig({
|
|||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
proxy: {
|
|
||||||
'/api': {
|
|
||||||
target: 'http://backend:5000',
|
|
||||||
changeOrigin: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user