Compare commits
38 Commits
dev
...
53b75588a2
| Author | SHA1 | Date | |
|---|---|---|---|
| 53b75588a2 | |||
| 1849ecd73b | |||
| 53b7d34520 | |||
| f65a4bed24 | |||
| cc4a2382c6 | |||
| 5735bb7735 | |||
| 88ff06237e | |||
| fbb13561b0 | |||
| b15861c856 | |||
| 2aeaa119f7 | |||
| 36690c0174 | |||
| b76ad35c07 | |||
| 6918b55a58 | |||
| 49f1713e26 | |||
| c9fa7e4246 | |||
| 0411ec5175 | |||
| 7b7171c109 | |||
| c02bb4d6d8 | |||
| 603b4eee4d | |||
| 52008f5b43 | |||
| 5b57cdd6bf | |||
| 60eadf0847 | |||
| 682ccfd41f | |||
| 59c6b19ad6 | |||
| aa1a4cf6ff | |||
| 5f0fad3cbb | |||
| 545fae59b2 | |||
| d748d2db21 | |||
| 095c91e56d | |||
| f2017068d5 | |||
| c9702495a3 | |||
| e965cb55ef | |||
| 505a9b8496 | |||
| c6dc5b22b8 | |||
| f16e88e2f0 | |||
| 016bc187d4 | |||
| 104f01f75d | |||
| 568aee3e70 |
@@ -3,13 +3,14 @@ name: Deploy
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
|
- main
|
||||||
- dev
|
- dev
|
||||||
- develop
|
- develop
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy-dev:
|
deploy-dev:
|
||||||
if: ${{ github.event_name == 'push' }}
|
if: ${{ github.event_name == 'push' && (github.ref_name == 'dev' || github.ref_name == 'develop') }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
volumes:
|
volumes:
|
||||||
@@ -48,7 +49,7 @@ jobs:
|
|||||||
docker compose -f docker-compose.dev.yaml up -d --build
|
docker compose -f docker-compose.dev.yaml up -d --build
|
||||||
|
|
||||||
deploy-prod:
|
deploy-prod:
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref_name == 'main') }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 242 KiB |
@@ -47,6 +47,23 @@ The default `docker-compose.yml` is development-only. It mounts source files, in
|
|||||||
|
|
||||||
## Operations
|
## Operations
|
||||||
|
|
||||||
|
### Health checks
|
||||||
|
|
||||||
|
Monitor these production checks:
|
||||||
|
|
||||||
|
- Frontend: `GET https://your-host/healthz`
|
||||||
|
- Verifies Nginx is serving the frontend container.
|
||||||
|
- Backend liveness: `GET https://your-host/api/health/live`
|
||||||
|
- Verifies the API process is running.
|
||||||
|
- Backend readiness: `GET https://your-host/api/health/ready`
|
||||||
|
- Verifies the API can reach Postgres and Redis. Returns `503` if either dependency is unavailable.
|
||||||
|
- Backend metrics: `GET https://your-host/api/metrics`
|
||||||
|
- Admin-authenticated process, request, and queue metrics.
|
||||||
|
- Postgres and Redis:
|
||||||
|
- Use the Docker health checks in `docker-compose.prod.yml`.
|
||||||
|
- Worker:
|
||||||
|
- Use the Docker health check in `docker-compose.prod.yml`; it validates worker dependencies. The worker does not expose HTTP.
|
||||||
|
|
||||||
### Backups
|
### Backups
|
||||||
|
|
||||||
Create a compressed Postgres backup from the Docker Compose Postgres service:
|
Create a compressed Postgres backup from the Docker Compose Postgres service:
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 234 KiB |
Generated
+1049
-1
File diff suppressed because it is too large
Load Diff
@@ -23,7 +23,10 @@
|
|||||||
"helmet": "8.1.0",
|
"helmet": "8.1.0",
|
||||||
"morgan": "1.10.0",
|
"morgan": "1.10.0",
|
||||||
"nodemailer": "^8.0.5",
|
"nodemailer": "^8.0.5",
|
||||||
|
"pdfkit": "^0.18.0",
|
||||||
"pg": "8.13.1",
|
"pg": "8.13.1",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"stripe": "^22.0.2",
|
"stripe": "^22.0.2",
|
||||||
"zod": "3.24.1"
|
"zod": "3.24.1"
|
||||||
},
|
},
|
||||||
@@ -32,7 +35,9 @@
|
|||||||
"@types/express": "4.17.21",
|
"@types/express": "4.17.21",
|
||||||
"@types/morgan": "1.9.9",
|
"@types/morgan": "1.9.9",
|
||||||
"@types/node": "22.10.2",
|
"@types/node": "22.10.2",
|
||||||
|
"@types/pdfkit": "^0.17.6",
|
||||||
"@types/pg": "8.11.10",
|
"@types/pg": "8.11.10",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"tsx": "4.19.2",
|
"tsx": "4.19.2",
|
||||||
"typescript": "5.7.2"
|
"typescript": "5.7.2"
|
||||||
}
|
}
|
||||||
|
|||||||
+938
-226
File diff suppressed because it is too large
Load Diff
+91
-39
@@ -30,9 +30,6 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
ALTER TABLE workspaces
|
ALTER TABLE workspaces
|
||||||
DROP CONSTRAINT IF EXISTS workspaces_id_check;
|
DROP CONSTRAINT IF EXISTS workspaces_id_check;
|
||||||
|
|
||||||
ALTER TABLE users
|
|
||||||
ADD COLUMN IF NOT EXISTS education_opt_out BOOLEAN NOT NULL DEFAULT FALSE;
|
|
||||||
|
|
||||||
ALTER TABLE workspaces
|
ALTER TABLE workspaces
|
||||||
ADD COLUMN IF NOT EXISTS billing_email VARCHAR(255),
|
ADD COLUMN IF NOT EXISTS billing_email VARCHAR(255),
|
||||||
ADD COLUMN IF NOT EXISTS billing_plan VARCHAR(32) NOT NULL DEFAULT 'household_basic',
|
ADD COLUMN IF NOT EXISTS billing_plan VARCHAR(32) NOT NULL DEFAULT 'household_basic',
|
||||||
@@ -142,37 +139,6 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_auth_sessions_created_user
|
CREATE INDEX IF NOT EXISTS idx_auth_sessions_created_user
|
||||||
ON auth_sessions (created_at DESC, user_id);
|
ON auth_sessions (created_at DESC, user_id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS daily_education (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
publish_date DATE NOT NULL UNIQUE,
|
|
||||||
fact TEXT NOT NULL,
|
|
||||||
quiz_questions JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
||||||
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER TABLE daily_education
|
|
||||||
ALTER COLUMN quiz_questions SET DEFAULT '[]'::jsonb;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_daily_education_publish_date
|
|
||||||
ON daily_education (publish_date DESC);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS education_question_bank (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
prompt VARCHAR(500) NOT NULL,
|
|
||||||
options JSONB NOT NULL,
|
|
||||||
correct_answer_index INTEGER NOT NULL,
|
|
||||||
explanation VARCHAR(800),
|
|
||||||
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
CHECK (correct_answer_index >= 0 AND correct_answer_index <= 3)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_education_question_bank_created
|
|
||||||
ON education_question_bank (created_at DESC);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS integration_tokens (
|
CREATE TABLE IF NOT EXISTS integration_tokens (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
@@ -249,6 +215,10 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
motivators VARCHAR(1000),
|
motivators VARCHAR(1000),
|
||||||
demotivators VARCHAR(1000),
|
demotivators VARCHAR(1000),
|
||||||
favorite_snack VARCHAR(160),
|
favorite_snack VARCHAR(160),
|
||||||
|
vet_clinic_name VARCHAR(160),
|
||||||
|
vet_clinic_address VARCHAR(500),
|
||||||
|
vet_account_number VARCHAR(120),
|
||||||
|
vet_doctor_name VARCHAR(160),
|
||||||
gender VARCHAR(16) NOT NULL DEFAULT 'unknown',
|
gender VARCHAR(16) NOT NULL DEFAULT 'unknown',
|
||||||
date_of_birth DATE,
|
date_of_birth DATE,
|
||||||
gotcha_day DATE,
|
gotcha_day DATE,
|
||||||
@@ -273,6 +243,10 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
ADD COLUMN IF NOT EXISTS motivators VARCHAR(1000),
|
ADD COLUMN IF NOT EXISTS motivators VARCHAR(1000),
|
||||||
ADD COLUMN IF NOT EXISTS demotivators VARCHAR(1000),
|
ADD COLUMN IF NOT EXISTS demotivators VARCHAR(1000),
|
||||||
ADD COLUMN IF NOT EXISTS favorite_snack VARCHAR(160),
|
ADD COLUMN IF NOT EXISTS favorite_snack VARCHAR(160),
|
||||||
|
ADD COLUMN IF NOT EXISTS vet_clinic_name VARCHAR(160),
|
||||||
|
ADD COLUMN IF NOT EXISTS vet_clinic_address VARCHAR(500),
|
||||||
|
ADD COLUMN IF NOT EXISTS vet_account_number VARCHAR(120),
|
||||||
|
ADD COLUMN IF NOT EXISTS vet_doctor_name VARCHAR(160),
|
||||||
ADD COLUMN IF NOT EXISTS gender VARCHAR(16) NOT NULL DEFAULT 'unknown',
|
ADD COLUMN IF NOT EXISTS gender VARCHAR(16) NOT NULL DEFAULT 'unknown',
|
||||||
ADD COLUMN IF NOT EXISTS date_of_birth DATE,
|
ADD COLUMN IF NOT EXISTS date_of_birth DATE,
|
||||||
ADD COLUMN IF NOT EXISTS gotcha_day DATE,
|
ADD COLUMN IF NOT EXISTS gotcha_day DATE,
|
||||||
@@ -318,8 +292,8 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
|
|
||||||
DROP INDEX IF EXISTS idx_birds_workspace_tag_id;
|
DROP INDEX IF EXISTS idx_birds_workspace_tag_id;
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_birds_workspace_tag_id
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_birds_global_tag_id
|
||||||
ON birds (workspace_id, LOWER(tag_id))
|
ON birds (LOWER(BTRIM(tag_id)))
|
||||||
WHERE tag_id IS NOT NULL
|
WHERE tag_id IS NOT NULL
|
||||||
AND BTRIM(tag_id) <> ''
|
AND BTRIM(tag_id) <> ''
|
||||||
AND LOWER(BTRIM(tag_id)) NOT IN ('unknown', 'not recorded', 'n/a', 'na', 'none');
|
AND LOWER(BTRIM(tag_id)) NOT IN ('unknown', 'not recorded', 'n/a', 'na', 'none');
|
||||||
@@ -347,8 +321,8 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
ON birds (public_profile_code)
|
ON birds (public_profile_code)
|
||||||
WHERE public_profile_code IS NOT NULL;
|
WHERE public_profile_code IS NOT NULL;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS pending_bird_transfers (
|
CREATE TABLE IF NOT EXISTS pending_bird_transfers (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
||||||
source_workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
source_workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
destination_owner_email VARCHAR(255) NOT NULL,
|
destination_owner_email VARCHAR(255) NOT NULL,
|
||||||
@@ -372,7 +346,67 @@ 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 weight_records (
|
CREATE TABLE IF NOT EXISTS bird_transfer_codes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
code VARCHAR(32) NOT NULL UNIQUE,
|
||||||
|
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
||||||
|
source_workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
|
requested_by_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
completed_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL,
|
||||||
|
revoked_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bird_transfer_codes_open_bird
|
||||||
|
ON bird_transfer_codes (bird_id, created_at DESC)
|
||||||
|
WHERE completed_at IS NULL
|
||||||
|
AND revoked_at IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bird_transfer_codes_code_open
|
||||||
|
ON bird_transfer_codes (code)
|
||||||
|
WHERE completed_at IS NULL
|
||||||
|
AND revoked_at IS NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS flock_notes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
|
bird_id UUID REFERENCES birds(id) ON DELETE SET NULL,
|
||||||
|
title VARCHAR(160) NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_flock_notes_workspace_updated
|
||||||
|
ON flock_notes (workspace_id, updated_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_flock_notes_bird_updated
|
||||||
|
ON flock_notes (bird_id, updated_at DESC)
|
||||||
|
WHERE bird_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_log_entries (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
actor_name VARCHAR(160),
|
||||||
|
actor_email VARCHAR(255),
|
||||||
|
action VARCHAR(80) NOT NULL,
|
||||||
|
entity_type VARCHAR(80) NOT NULL,
|
||||||
|
entity_id VARCHAR(120),
|
||||||
|
entity_name VARCHAR(255),
|
||||||
|
details JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_log_entries_workspace_created
|
||||||
|
ON audit_log_entries (workspace_id, created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_log_entries_entity
|
||||||
|
ON audit_log_entries (workspace_id, entity_type, entity_id, created_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS weight_records (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
||||||
weight_grams NUMERIC(8, 2) NOT NULL CHECK (weight_grams > 0),
|
weight_grams NUMERIC(8, 2) NOT NULL CHECK (weight_grams > 0),
|
||||||
@@ -403,6 +437,7 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
start_date DATE NOT NULL,
|
start_date DATE NOT NULL,
|
||||||
end_date DATE,
|
end_date DATE,
|
||||||
notes VARCHAR(1000),
|
notes VARCHAR(1000),
|
||||||
|
reminders_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
CHECK (end_date IS NULL OR end_date >= start_date)
|
CHECK (end_date IS NULL OR end_date >= start_date)
|
||||||
);
|
);
|
||||||
@@ -410,6 +445,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
ALTER TABLE medications
|
ALTER TABLE medications
|
||||||
ADD COLUMN IF NOT EXISTS dose_schedule JSONB NOT NULL DEFAULT '[{"key":"dose-1","label":"Dose","time":""}]'::jsonb;
|
ADD COLUMN IF NOT EXISTS dose_schedule JSONB NOT NULL DEFAULT '[{"key":"dose-1","label":"Dose","time":""}]'::jsonb;
|
||||||
|
|
||||||
|
ALTER TABLE medications
|
||||||
|
ADD COLUMN IF NOT EXISTS reminders_enabled BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS bird_milestone_reminder_deliveries (
|
CREATE TABLE IF NOT EXISTS bird_milestone_reminder_deliveries (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
||||||
@@ -443,6 +481,17 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
ALTER TABLE medication_administrations
|
ALTER TABLE medication_administrations
|
||||||
ADD COLUMN IF NOT EXISTS administration_slot VARCHAR(80) NOT NULL DEFAULT 'dose-1';
|
ADD COLUMN IF NOT EXISTS administration_slot VARCHAR(80) NOT NULL DEFAULT 'dose-1';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS medication_reminder_deliveries (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
medication_id UUID NOT NULL REFERENCES medications(id) ON DELETE CASCADE,
|
||||||
|
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
||||||
|
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
|
scheduled_on DATE NOT NULL,
|
||||||
|
administration_slot VARCHAR(80) NOT NULL,
|
||||||
|
delivered_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (medication_id, scheduled_on, administration_slot)
|
||||||
|
);
|
||||||
|
|
||||||
ALTER TABLE medication_administrations
|
ALTER TABLE medication_administrations
|
||||||
DROP CONSTRAINT IF EXISTS medication_administrations_medication_id_administered_on_key;
|
DROP CONSTRAINT IF EXISTS medication_administrations_medication_id_administered_on_key;
|
||||||
|
|
||||||
@@ -461,6 +510,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_bird_milestone_reminder_deliveries_workspace
|
CREATE INDEX IF NOT EXISTS idx_bird_milestone_reminder_deliveries_workspace
|
||||||
ON bird_milestone_reminder_deliveries (workspace_id, delivered_on DESC);
|
ON bird_milestone_reminder_deliveries (workspace_id, delivered_on DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_medication_reminder_deliveries_workspace
|
||||||
|
ON medication_reminder_deliveries (workspace_id, scheduled_on DESC);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_medication_administrations_bird_administered_on
|
CREATE INDEX IF NOT EXISTS idx_medication_administrations_bird_administered_on
|
||||||
ON medication_administrations (bird_id, administered_on DESC);
|
ON medication_administrations (bird_id, administered_on DESC);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { db } from './db/client.js';
|
||||||
|
import { closeBirdMilestoneReminderQueue, getBirdMilestoneReminderQueueCounts } from './queues/birdMilestoneReminderQueue.js';
|
||||||
|
|
||||||
|
const timeoutMs = Number(process.env.HEALTHCHECK_TIMEOUT_MS ?? 5_000);
|
||||||
|
|
||||||
|
const withTimeout = async <T>(operation: Promise<T>, label: string): Promise<T> => {
|
||||||
|
let timeout: NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
operation,
|
||||||
|
new Promise<never>((_resolve, reject) => {
|
||||||
|
timeout = setTimeout(() => reject(new Error(`${label} timed out`)), timeoutMs);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
if (timeout) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkHttp = async (path: string) => {
|
||||||
|
const port = process.env.PORT ?? '5000';
|
||||||
|
const response = await withTimeout(fetch(`http://127.0.0.1:${port}${path}`), path);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`${path} returned ${response.status}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkWorkerDependencies = async () => {
|
||||||
|
await withTimeout(db.query('SELECT 1'), 'postgres');
|
||||||
|
await withTimeout(getBirdMilestoneReminderQueueCounts(), 'redis');
|
||||||
|
};
|
||||||
|
|
||||||
|
const mode = process.argv[2] ?? 'api-ready';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (mode === 'api-live') {
|
||||||
|
await checkHttp('/api/health/live');
|
||||||
|
} else if (mode === 'api-ready') {
|
||||||
|
await checkHttp('/api/health/ready');
|
||||||
|
} else if (mode === 'worker') {
|
||||||
|
await checkWorkerDependencies();
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unknown healthcheck mode: ${mode}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error instanceof Error ? error.message : error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
await Promise.allSettled([closeBirdMilestoneReminderQueue(), db.close()]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAdoptionReportQueueCounts = () => adoptionReportQueue.getJobCounts('waiting', 'active', 'delayed', 'completed', 'failed');
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Queue, type Job } from 'bullmq';
|
||||||
|
|
||||||
|
import { redisConnection } from './redisConnection.js';
|
||||||
|
|
||||||
|
export type MedicationReminderJobData = {
|
||||||
|
runDate: string;
|
||||||
|
currentTime: string;
|
||||||
|
requestedBy: 'scheduler';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MedicationReminderJobResult = {
|
||||||
|
runDate: string;
|
||||||
|
currentTime: string;
|
||||||
|
checked: number;
|
||||||
|
sent: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const medicationReminderQueueName = 'medication-reminders';
|
||||||
|
|
||||||
|
export const medicationReminderQueue = new Queue<MedicationReminderJobData, MedicationReminderJobResult>(medicationReminderQueueName, {
|
||||||
|
connection: redisConnection,
|
||||||
|
defaultJobOptions: {
|
||||||
|
attempts: 3,
|
||||||
|
backoff: {
|
||||||
|
type: 'exponential',
|
||||||
|
delay: 60_000,
|
||||||
|
},
|
||||||
|
removeOnComplete: 100,
|
||||||
|
removeOnFail: 1_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const enqueueMedicationReminderJob = (
|
||||||
|
runDate: string,
|
||||||
|
currentTime: string,
|
||||||
|
): Promise<Job<MedicationReminderJobData, MedicationReminderJobResult>> =>
|
||||||
|
medicationReminderQueue.add(
|
||||||
|
'run-medication-reminders',
|
||||||
|
{
|
||||||
|
runDate,
|
||||||
|
currentTime,
|
||||||
|
requestedBy: 'scheduler',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jobId: `medication-reminders-${runDate}-${currentTime.slice(0, 2)}`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const closeMedicationReminderQueue = async () => {
|
||||||
|
await medicationReminderQueue.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMedicationReminderQueueCounts = () => medicationReminderQueue.getJobCounts('waiting', 'active', 'delayed', 'completed', 'failed');
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import PDFDocument from 'pdfkit';
|
||||||
|
import QRCode from 'qrcode';
|
||||||
|
|
||||||
|
import type { BirdRow, FlockNoteRow, VetVisitRow, WeightRow } from '../types.js';
|
||||||
|
|
||||||
|
type AdoptionReportInput = {
|
||||||
|
bird: BirdRow;
|
||||||
|
weights: WeightRow[];
|
||||||
|
vetVisits: VetVisitRow[];
|
||||||
|
notes: FlockNoteRow[];
|
||||||
|
transferCode: string;
|
||||||
|
birdPhotoBuffer?: Buffer | null;
|
||||||
|
assets: {
|
||||||
|
logoPath: string;
|
||||||
|
wordmarkPath: string;
|
||||||
|
defaultBirdPhotoPath: string;
|
||||||
|
};
|
||||||
|
printFriendly?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const page = { width: 612, height: 792, margin: 42 };
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
ink: '#1f2a2a',
|
||||||
|
muted: '#5d5f59',
|
||||||
|
red: '#cb3a35',
|
||||||
|
green: '#238a5a',
|
||||||
|
blue: '#2769b3',
|
||||||
|
border: '#cfe0d5',
|
||||||
|
panel: '#fbf7ee',
|
||||||
|
paper: '#fffdf9',
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (value: string | null) => {
|
||||||
|
if (!value) {
|
||||||
|
return 'Not recorded';
|
||||||
|
}
|
||||||
|
return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }).format(
|
||||||
|
new Date(`${value.slice(0, 10)}T00:00:00Z`),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDateTime = (value: string | null) => {
|
||||||
|
if (!value) {
|
||||||
|
return 'Not recorded';
|
||||||
|
}
|
||||||
|
return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(new Date(value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatShortDate = (value: string | null) => {
|
||||||
|
if (!value) {
|
||||||
|
return 'No data yet';
|
||||||
|
}
|
||||||
|
return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' }).format(new Date(`${value.slice(0, 10)}T00:00:00Z`));
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatWeight = (value: string | number | null) => {
|
||||||
|
const numericValue = value === null ? null : Number(value);
|
||||||
|
return numericValue && Number.isFinite(numericValue) ? `${numericValue.toFixed(1)} g` : 'Pending';
|
||||||
|
};
|
||||||
|
|
||||||
|
const genderLabel = (value: string) => {
|
||||||
|
if (value === 'female') {
|
||||||
|
return 'Female';
|
||||||
|
}
|
||||||
|
if (value === 'male') {
|
||||||
|
return 'Male';
|
||||||
|
}
|
||||||
|
return 'Unknown';
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseList = (value: string | null) =>
|
||||||
|
(value ?? '')
|
||||||
|
.split(/\r?\n|,/)
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const dataUrlToBuffer = (value: string | null) => {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const match = value.match(/^data:image\/(?:png|jpeg|jpg);base64,(.+)$/);
|
||||||
|
return match ? Buffer.from(match[1], 'base64') : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectPdf = (doc: PDFKit.PDFDocument) =>
|
||||||
|
new Promise<Buffer>((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
|
doc.on('error', reject);
|
||||||
|
});
|
||||||
|
|
||||||
|
const fitText = (doc: PDFKit.PDFDocument, text: string, x: number, y: number, width: number, options: PDFKit.Mixins.TextOptions = {}) => {
|
||||||
|
doc.text(text, x, y, { width, lineGap: 1.5, ...options });
|
||||||
|
return doc.y;
|
||||||
|
};
|
||||||
|
|
||||||
|
const measureFactHeight = (doc: PDFKit.PDFDocument, value: string, width: number, minHeight = 43) => {
|
||||||
|
doc.font('Helvetica-Bold').fontSize(10);
|
||||||
|
const textHeight = doc.heightOfString(value, {
|
||||||
|
width: width - 16,
|
||||||
|
lineGap: 1,
|
||||||
|
});
|
||||||
|
return Math.max(minHeight, 27 + Math.min(textHeight, 38));
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawFact = (doc: PDFKit.PDFDocument, label: string, value: string, x: number, y: number, width: number, height?: number) => {
|
||||||
|
const cardHeight = height ?? measureFactHeight(doc, value, width);
|
||||||
|
doc.roundedRect(x, y, width, cardHeight, 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,
|
||||||
|
height: cardHeight - 27,
|
||||||
|
lineGap: 1,
|
||||||
|
ellipsis: true,
|
||||||
|
});
|
||||||
|
return cardHeight;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 earliestDate = new Date(`${plottedWeights[0].recorded_on.slice(0, 10)}T00:00:00Z`);
|
||||||
|
const startDate = new Date(latestDate);
|
||||||
|
startDate.setUTCDate(startDate.getUTCDate() - 13);
|
||||||
|
if (earliestDate > startDate) {
|
||||||
|
startDate.setTime(earliestDate.getTime());
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
drawFact(doc, 'Clinic name', bird.vet_clinic_name || 'Not recorded', page.margin, y, factWidth);
|
||||||
|
drawFact(doc, 'Account #', bird.vet_account_number || 'Not recorded', page.margin + factWidth + factGap, y, factWidth);
|
||||||
|
y += 50;
|
||||||
|
const clinicAddressHeight = measureFactHeight(doc, bird.vet_clinic_address || 'Not recorded', contentWidth, 58);
|
||||||
|
drawFact(doc, 'Clinic address', bird.vet_clinic_address || 'Not recorded', page.margin, y, contentWidth, clinicAddressHeight);
|
||||||
|
y += clinicAddressHeight + 7;
|
||||||
|
drawFact(doc, 'Dr. name', bird.vet_doctor_name || 'Not recorded', page.margin, y, factWidth);
|
||||||
|
y += 50;
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import path from 'path';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getBirdById,
|
||||||
|
listVetVisitsForBird,
|
||||||
|
listWeightsForBird,
|
||||||
|
} from '../repositories/birdRepository.js';
|
||||||
|
import { listFlockNotes } from '../repositories/auditRepository.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'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { db } from '../db/client.js';
|
||||||
|
import type { AuditLogEntryRow, AuthContext, FlockNoteRow } from '../types.js';
|
||||||
|
|
||||||
|
type AuditLogInput = {
|
||||||
|
workspaceId: number;
|
||||||
|
auth?: AuthContext;
|
||||||
|
action: string;
|
||||||
|
entityType: string;
|
||||||
|
entityId?: string | null;
|
||||||
|
entityName?: string | null;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createAuditLogEntry = async ({
|
||||||
|
workspaceId,
|
||||||
|
auth,
|
||||||
|
action,
|
||||||
|
entityType,
|
||||||
|
entityId = null,
|
||||||
|
entityName = null,
|
||||||
|
details = {},
|
||||||
|
}: AuditLogInput) => {
|
||||||
|
const result = await db.query<AuditLogEntryRow>(
|
||||||
|
`INSERT INTO audit_log_entries (workspace_id, user_id, actor_name, actor_email, action, entity_type, entity_id, entity_name, details)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
RETURNING id, workspace_id, user_id, actor_name, actor_email, action, entity_type, entity_id, entity_name, details, created_at`,
|
||||||
|
[
|
||||||
|
workspaceId,
|
||||||
|
auth?.user.id ?? null,
|
||||||
|
auth?.user.name ?? null,
|
||||||
|
auth?.user.email ?? null,
|
||||||
|
action,
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
entityName,
|
||||||
|
JSON.stringify(details),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listAuditLogEntries = async (workspaceId: number, limit = 100) => {
|
||||||
|
const result = await db.query<AuditLogEntryRow>(
|
||||||
|
`SELECT id, workspace_id, user_id, actor_name, actor_email, action, entity_type, entity_id, entity_name, details, created_at
|
||||||
|
FROM audit_log_entries
|
||||||
|
WHERE workspace_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2`,
|
||||||
|
[workspaceId, limit],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listFlockNotes = async (workspaceId: number) => {
|
||||||
|
const result = await db.query<FlockNoteRow>(
|
||||||
|
`SELECT flock_notes.id,
|
||||||
|
flock_notes.workspace_id,
|
||||||
|
flock_notes.bird_id,
|
||||||
|
birds.name AS bird_name,
|
||||||
|
flock_notes.title,
|
||||||
|
flock_notes.body,
|
||||||
|
flock_notes.created_by_user_id,
|
||||||
|
users.name AS created_by_name,
|
||||||
|
flock_notes.created_at,
|
||||||
|
flock_notes.updated_at
|
||||||
|
FROM flock_notes
|
||||||
|
LEFT JOIN birds ON birds.id = flock_notes.bird_id
|
||||||
|
LEFT JOIN users ON users.id = flock_notes.created_by_user_id
|
||||||
|
WHERE flock_notes.workspace_id = $1
|
||||||
|
ORDER BY flock_notes.updated_at DESC`,
|
||||||
|
[workspaceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createFlockNote = async ({
|
||||||
|
workspaceId,
|
||||||
|
birdId,
|
||||||
|
body,
|
||||||
|
createdByUserId,
|
||||||
|
}: {
|
||||||
|
workspaceId: number;
|
||||||
|
birdId: string | null;
|
||||||
|
body: string;
|
||||||
|
createdByUserId: string | null;
|
||||||
|
}) => {
|
||||||
|
const title = body.split(/\s+/).join(' ').slice(0, 160) || 'Note';
|
||||||
|
const result = await db.query<FlockNoteRow>(
|
||||||
|
`WITH inserted_note AS (
|
||||||
|
INSERT INTO flock_notes (workspace_id, bird_id, title, body, created_by_user_id)
|
||||||
|
SELECT $1, $2, $3, $4, $5
|
||||||
|
WHERE $2::uuid IS NULL
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM birds
|
||||||
|
WHERE birds.id = $2
|
||||||
|
AND birds.workspace_id = $1
|
||||||
|
)
|
||||||
|
RETURNING id, workspace_id, bird_id, title, body, created_by_user_id, created_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT inserted_note.id,
|
||||||
|
inserted_note.workspace_id,
|
||||||
|
inserted_note.bird_id,
|
||||||
|
birds.name AS bird_name,
|
||||||
|
inserted_note.title,
|
||||||
|
inserted_note.body,
|
||||||
|
inserted_note.created_by_user_id,
|
||||||
|
users.name AS created_by_name,
|
||||||
|
inserted_note.created_at,
|
||||||
|
inserted_note.updated_at
|
||||||
|
FROM inserted_note
|
||||||
|
LEFT JOIN birds ON birds.id = inserted_note.bird_id
|
||||||
|
LEFT JOIN users ON users.id = inserted_note.created_by_user_id`,
|
||||||
|
[workspaceId, birdId, title, body, createdByUserId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteFlockNote = async (noteId: string, workspaceId: number) => {
|
||||||
|
const result = await db.query<{ id: string; title: string }>(
|
||||||
|
`DELETE FROM flock_notes
|
||||||
|
WHERE id = $1
|
||||||
|
AND workspace_id = $2
|
||||||
|
RETURNING id, title`,
|
||||||
|
[noteId, workspaceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
};
|
||||||
@@ -6,7 +6,10 @@ import {
|
|||||||
createBird,
|
createBird,
|
||||||
createPendingBirdTransfer,
|
createPendingBirdTransfer,
|
||||||
getBirdById,
|
getBirdById,
|
||||||
|
getOpenBirdTransferCode,
|
||||||
|
getOpenBirdTransferCodeForBird,
|
||||||
listWeightsForBird,
|
listWeightsForBird,
|
||||||
|
markBirdTransferCodeCompleted,
|
||||||
transferBirdToWorkspace,
|
transferBirdToWorkspace,
|
||||||
} from './birdRepository.js';
|
} from './birdRepository.js';
|
||||||
import { mockDb } from '../test/mockDb.js';
|
import { mockDb } from '../test/mockDb.js';
|
||||||
@@ -198,3 +201,36 @@ test('completePendingBirdTransfersForOwner moves pending birds and marks complet
|
|||||||
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/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getOpenBirdTransferCode only returns unconsumed codes', async () => {
|
||||||
|
const { calls } = mockDb({ rowCount: 0, rows: [] });
|
||||||
|
|
||||||
|
const transferCode = await getOpenBirdTransferCode('ADOPT-123');
|
||||||
|
|
||||||
|
assert.equal(transferCode, null);
|
||||||
|
assert.deepEqual(calls[0].params, ['ADOPT-123']);
|
||||||
|
assert.match(calls[0].text, /bird_transfer_codes\.completed_at IS NULL/);
|
||||||
|
assert.match(calls[0].text, /bird_transfer_codes\.revoked_at IS NULL/);
|
||||||
|
assert.match(calls[0].text, /birds\.workspace_id = bird_transfer_codes\.source_workspace_id/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getOpenBirdTransferCodeForBird ignores consumed codes', async () => {
|
||||||
|
const { calls } = mockDb({ rowCount: 0, rows: [] });
|
||||||
|
|
||||||
|
const transferCode = await getOpenBirdTransferCodeForBird('bird-1', 10);
|
||||||
|
|
||||||
|
assert.equal(transferCode, null);
|
||||||
|
assert.deepEqual(calls[0].params, ['bird-1', 10]);
|
||||||
|
assert.match(calls[0].text, /completed_at IS NULL/);
|
||||||
|
assert.match(calls[0].text, /revoked_at IS NULL/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('markBirdTransferCodeCompleted consumes a code for the receiving workspace', async () => {
|
||||||
|
const { calls } = mockDb({ rowCount: 1, rows: [] });
|
||||||
|
|
||||||
|
await markBirdTransferCodeCompleted('code-1', 22);
|
||||||
|
|
||||||
|
assert.deepEqual(calls[0].params, ['code-1', 22]);
|
||||||
|
assert.match(calls[0].text, /SET completed_at = CURRENT_TIMESTAMP/);
|
||||||
|
assert.match(calls[0].text, /completed_workspace_id = \$2/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ import type {
|
|||||||
BirdMilestoneReminderDeliveryRow,
|
BirdMilestoneReminderDeliveryRow,
|
||||||
BirdMilestoneReminderType,
|
BirdMilestoneReminderType,
|
||||||
BirdRow,
|
BirdRow,
|
||||||
|
BirdTransferCodeRow,
|
||||||
LostBirdMatchRow,
|
LostBirdMatchRow,
|
||||||
MedicationAdministrationRow,
|
MedicationAdministrationRow,
|
||||||
MedicationDoseScheduleItem,
|
MedicationDoseScheduleItem,
|
||||||
|
MedicationReminderCandidateRow,
|
||||||
|
MedicationReminderDeliveryRow,
|
||||||
MedicationRow,
|
MedicationRow,
|
||||||
PendingBirdTransferRow,
|
PendingBirdTransferRow,
|
||||||
VetVisitRow,
|
VetVisitRow,
|
||||||
@@ -23,6 +26,10 @@ const birdSelectFields = `
|
|||||||
birds.motivators,
|
birds.motivators,
|
||||||
birds.demotivators,
|
birds.demotivators,
|
||||||
birds.favorite_snack,
|
birds.favorite_snack,
|
||||||
|
birds.vet_clinic_name,
|
||||||
|
birds.vet_clinic_address,
|
||||||
|
birds.vet_account_number,
|
||||||
|
birds.vet_doctor_name,
|
||||||
birds.gender,
|
birds.gender,
|
||||||
birds.date_of_birth::text,
|
birds.date_of_birth::text,
|
||||||
birds.gotcha_day::text,
|
birds.gotcha_day::text,
|
||||||
@@ -278,6 +285,79 @@ export const createBirdMilestoneReminderDelivery = async ({
|
|||||||
return result.rows[0] ?? null;
|
return result.rows[0] ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const listDueMedicationReminders = async (runDate: string, currentTime: string) => {
|
||||||
|
const result = await db.query<MedicationReminderCandidateRow>(
|
||||||
|
`SELECT
|
||||||
|
${birdSelectFields},
|
||||||
|
workspaces.name AS workspace_name,
|
||||||
|
medications.id AS medication_id,
|
||||||
|
medications.name AS medication_name,
|
||||||
|
medications.dosage,
|
||||||
|
medications.frequency,
|
||||||
|
medications.dose_schedule,
|
||||||
|
medications.route,
|
||||||
|
medications.start_date::text AS medication_start_date,
|
||||||
|
medications.end_date::text AS medication_end_date,
|
||||||
|
medications.notes AS medication_notes,
|
||||||
|
$1::date::text AS scheduled_on,
|
||||||
|
dose.key AS administration_slot,
|
||||||
|
dose.label AS administration_label,
|
||||||
|
dose.time AS administration_time
|
||||||
|
FROM medications
|
||||||
|
INNER JOIN birds ON birds.id = medications.bird_id
|
||||||
|
INNER JOIN workspaces ON workspaces.id = birds.workspace_id
|
||||||
|
CROSS JOIN LATERAL jsonb_to_recordset(medications.dose_schedule) AS dose(key text, label text, time text)
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT weight_grams, recorded_on
|
||||||
|
FROM weight_records
|
||||||
|
WHERE weight_records.bird_id = birds.id
|
||||||
|
ORDER BY recorded_on DESC
|
||||||
|
LIMIT 1
|
||||||
|
) latest ON TRUE
|
||||||
|
WHERE medications.reminders_enabled = TRUE
|
||||||
|
AND birds.memorialized_at IS NULL
|
||||||
|
AND medications.start_date <= $1::date
|
||||||
|
AND (medications.end_date IS NULL OR medications.end_date >= $1::date)
|
||||||
|
AND COALESCE(NULLIF(BTRIM(dose.time), ''), '') <> ''
|
||||||
|
AND dose.time <= $2
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM medication_reminder_deliveries deliveries
|
||||||
|
WHERE deliveries.medication_id = medications.id
|
||||||
|
AND deliveries.scheduled_on = $1::date
|
||||||
|
AND deliveries.administration_slot = dose.key
|
||||||
|
)
|
||||||
|
ORDER BY workspaces.name ASC, birds.name ASC, dose.time ASC, medications.name ASC`,
|
||||||
|
[runDate, currentTime],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createMedicationReminderDelivery = async ({
|
||||||
|
medicationId,
|
||||||
|
birdId,
|
||||||
|
workspaceId,
|
||||||
|
scheduledOn,
|
||||||
|
administrationSlot,
|
||||||
|
}: {
|
||||||
|
medicationId: string;
|
||||||
|
birdId: string;
|
||||||
|
workspaceId: number;
|
||||||
|
scheduledOn: string;
|
||||||
|
administrationSlot: string;
|
||||||
|
}) => {
|
||||||
|
const result = await db.query<MedicationReminderDeliveryRow>(
|
||||||
|
`INSERT INTO medication_reminder_deliveries (medication_id, bird_id, workspace_id, scheduled_on, administration_slot)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (medication_id, scheduled_on, administration_slot) DO NOTHING
|
||||||
|
RETURNING id, medication_id, bird_id, workspace_id, scheduled_on::text, administration_slot, delivered_at`,
|
||||||
|
[medicationId, birdId, workspaceId, scheduledOn, administrationSlot],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
export const createBird = async ({
|
export const createBird = async ({
|
||||||
birdId,
|
birdId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -287,6 +367,10 @@ export const createBird = async ({
|
|||||||
motivators,
|
motivators,
|
||||||
demotivators,
|
demotivators,
|
||||||
favoriteSnack,
|
favoriteSnack,
|
||||||
|
vetClinicName = null,
|
||||||
|
vetClinicAddress = null,
|
||||||
|
vetAccountNumber = null,
|
||||||
|
vetDoctorName = null,
|
||||||
gender,
|
gender,
|
||||||
dateOfBirth,
|
dateOfBirth,
|
||||||
gotchaDay,
|
gotchaDay,
|
||||||
@@ -308,6 +392,10 @@ export const createBird = async ({
|
|||||||
motivators: string | null;
|
motivators: string | null;
|
||||||
demotivators: string | null;
|
demotivators: string | null;
|
||||||
favoriteSnack: string | null;
|
favoriteSnack: string | null;
|
||||||
|
vetClinicName?: string | null;
|
||||||
|
vetClinicAddress?: string | null;
|
||||||
|
vetAccountNumber?: string | null;
|
||||||
|
vetDoctorName?: string | null;
|
||||||
gender: BirdGender;
|
gender: BirdGender;
|
||||||
dateOfBirth: string | null;
|
dateOfBirth: string | null;
|
||||||
gotchaDay: string | null;
|
gotchaDay: string | null;
|
||||||
@@ -322,9 +410,9 @@ export const createBird = async ({
|
|||||||
publicProfileEnabled?: boolean;
|
publicProfileEnabled?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const result = await db.query<BirdRow>(
|
const result = await db.query<BirdRow>(
|
||||||
`INSERT INTO birds (id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, gender, date_of_birth, gotcha_day, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled)
|
`INSERT INTO birds (id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth, gotcha_day, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled)
|
||||||
VALUES (COALESCE($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
|
VALUES (COALESCE($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)
|
||||||
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`,
|
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`,
|
||||||
[
|
[
|
||||||
birdId ?? null,
|
birdId ?? null,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -334,6 +422,10 @@ export const createBird = async ({
|
|||||||
motivators,
|
motivators,
|
||||||
demotivators,
|
demotivators,
|
||||||
favoriteSnack,
|
favoriteSnack,
|
||||||
|
vetClinicName,
|
||||||
|
vetClinicAddress,
|
||||||
|
vetAccountNumber,
|
||||||
|
vetDoctorName,
|
||||||
gender,
|
gender,
|
||||||
dateOfBirth,
|
dateOfBirth,
|
||||||
gotchaDay,
|
gotchaDay,
|
||||||
@@ -361,6 +453,10 @@ export const updateBird = async ({
|
|||||||
motivators,
|
motivators,
|
||||||
demotivators,
|
demotivators,
|
||||||
favoriteSnack,
|
favoriteSnack,
|
||||||
|
vetClinicName,
|
||||||
|
vetClinicAddress,
|
||||||
|
vetAccountNumber,
|
||||||
|
vetDoctorName,
|
||||||
gender,
|
gender,
|
||||||
dateOfBirth,
|
dateOfBirth,
|
||||||
gotchaDay,
|
gotchaDay,
|
||||||
@@ -382,6 +478,10 @@ export const updateBird = async ({
|
|||||||
motivators: string | null;
|
motivators: string | null;
|
||||||
demotivators: string | null;
|
demotivators: string | null;
|
||||||
favoriteSnack: string | null;
|
favoriteSnack: string | null;
|
||||||
|
vetClinicName: string | null;
|
||||||
|
vetClinicAddress: string | null;
|
||||||
|
vetAccountNumber: string | null;
|
||||||
|
vetDoctorName: string | null;
|
||||||
gender: BirdGender;
|
gender: BirdGender;
|
||||||
dateOfBirth: string | null;
|
dateOfBirth: string | null;
|
||||||
gotchaDay: string | null;
|
gotchaDay: string | null;
|
||||||
@@ -403,22 +503,26 @@ export const updateBird = async ({
|
|||||||
motivators = $5,
|
motivators = $5,
|
||||||
demotivators = $6,
|
demotivators = $6,
|
||||||
favorite_snack = $7,
|
favorite_snack = $7,
|
||||||
gender = $8,
|
vet_clinic_name = $8,
|
||||||
date_of_birth = $9,
|
vet_clinic_address = $9,
|
||||||
gotcha_day = $10,
|
vet_account_number = $10,
|
||||||
chart_color = $11,
|
vet_doctor_name = $11,
|
||||||
photo_data_url = $12,
|
gender = $12,
|
||||||
photo_object_key = $13,
|
date_of_birth = $13,
|
||||||
photo_content_type = $14,
|
gotcha_day = $14,
|
||||||
photo_updated_at = $15,
|
chart_color = $15,
|
||||||
notify_on_dob = $16,
|
photo_data_url = $16,
|
||||||
notify_on_gotcha_day = $17,
|
photo_object_key = $17,
|
||||||
public_profile_code = $18,
|
photo_content_type = $18,
|
||||||
public_profile_enabled = $19
|
photo_updated_at = $19,
|
||||||
|
notify_on_dob = $20,
|
||||||
|
notify_on_gotcha_day = $21,
|
||||||
|
public_profile_code = $22,
|
||||||
|
public_profile_enabled = $23
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND workspace_id = $20
|
AND workspace_id = $24
|
||||||
AND memorialized_at IS NULL
|
AND memorialized_at IS NULL
|
||||||
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
||||||
(
|
(
|
||||||
SELECT weight_grams::text
|
SELECT weight_grams::text
|
||||||
FROM weight_records
|
FROM weight_records
|
||||||
@@ -441,6 +545,10 @@ export const updateBird = async ({
|
|||||||
motivators,
|
motivators,
|
||||||
demotivators,
|
demotivators,
|
||||||
favoriteSnack,
|
favoriteSnack,
|
||||||
|
vetClinicName,
|
||||||
|
vetClinicAddress,
|
||||||
|
vetAccountNumber,
|
||||||
|
vetDoctorName,
|
||||||
gender,
|
gender,
|
||||||
dateOfBirth,
|
dateOfBirth,
|
||||||
gotchaDay,
|
gotchaDay,
|
||||||
@@ -482,7 +590,7 @@ export const memorializeBird = async ({
|
|||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND workspace_id = $2
|
AND workspace_id = $2
|
||||||
AND memorialized_at IS NULL
|
AND memorialized_at IS NULL
|
||||||
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
||||||
(
|
(
|
||||||
SELECT weight_grams::text
|
SELECT weight_grams::text
|
||||||
FROM weight_records
|
FROM weight_records
|
||||||
@@ -518,7 +626,7 @@ export const updateMemorialReminderPreference = async ({
|
|||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND workspace_id = $2
|
AND workspace_id = $2
|
||||||
AND memorialized_at IS NOT NULL
|
AND memorialized_at IS NOT NULL
|
||||||
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
||||||
(
|
(
|
||||||
SELECT weight_grams::text
|
SELECT weight_grams::text
|
||||||
FROM weight_records
|
FROM weight_records
|
||||||
@@ -558,7 +666,7 @@ export const transferBirdToWorkspace = async (birdId: string, sourceWorkspaceId:
|
|||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND workspace_id = $2
|
AND workspace_id = $2
|
||||||
AND memorialized_at IS NULL
|
AND memorialized_at IS NULL
|
||||||
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
||||||
(
|
(
|
||||||
SELECT weight_grams::text
|
SELECT weight_grams::text
|
||||||
FROM weight_records
|
FROM weight_records
|
||||||
@@ -659,7 +767,7 @@ export const completePendingBirdTransfersForOwner = async (ownerEmail: string, t
|
|||||||
failed += 1;
|
failed += 1;
|
||||||
const message =
|
const message =
|
||||||
typeof error === 'object' && error && 'code' in error && error.code === '23505'
|
typeof error === 'object' && error && 'code' in error && error.code === '23505'
|
||||||
? 'The receiving flock already has a bird using the same band/tag ID.'
|
? 'That band/tag ID is already in use in FlockPal.'
|
||||||
: error instanceof Error
|
: error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: 'Unable to complete pending bird transfer.';
|
: 'Unable to complete pending bird transfer.';
|
||||||
@@ -670,6 +778,109 @@ 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 getOpenBirdTransferCodeForBird = async (birdId: string, sourceWorkspaceId: number) => {
|
||||||
|
const result = await db.query<BirdTransferCodeRow>(
|
||||||
|
`SELECT id, code, bird_id, source_workspace_id, requested_by_user_id, completed_at::text, completed_workspace_id, revoked_at::text, created_at
|
||||||
|
FROM bird_transfer_codes
|
||||||
|
WHERE bird_id = $1
|
||||||
|
AND source_workspace_id = $2
|
||||||
|
AND completed_at IS NULL
|
||||||
|
AND revoked_at IS NULL
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
[birdId, sourceWorkspaceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
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
|
||||||
@@ -700,6 +911,34 @@ export const createWeightForBird = async (birdId: string, weightGrams: number, r
|
|||||||
return result.rows[0] ?? null;
|
return result.rows[0] ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const updateWeightForBird = async (
|
||||||
|
weightId: string,
|
||||||
|
birdId: string,
|
||||||
|
weightGrams: number,
|
||||||
|
recordedOn: string,
|
||||||
|
notes: string | null,
|
||||||
|
) => {
|
||||||
|
const result = await db.query<WeightRow>(
|
||||||
|
`UPDATE weight_records
|
||||||
|
SET weight_grams = $3,
|
||||||
|
recorded_on = $4,
|
||||||
|
notes = $5
|
||||||
|
WHERE id = $1
|
||||||
|
AND bird_id = $2
|
||||||
|
AND id IN (
|
||||||
|
SELECT recent.id
|
||||||
|
FROM weight_records recent
|
||||||
|
WHERE recent.bird_id = $2
|
||||||
|
ORDER BY recent.recorded_on DESC, recent.created_at DESC
|
||||||
|
LIMIT 3
|
||||||
|
)
|
||||||
|
RETURNING id, bird_id, weight_grams, recorded_on::text, notes`,
|
||||||
|
[weightId, birdId, weightGrams, recordedOn, notes],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
export const listVetVisitsForBird = async (birdId: string, workspaceId: number) => {
|
export const listVetVisitsForBird = async (birdId: string, workspaceId: number) => {
|
||||||
const result = await db.query<VetVisitRow>(
|
const result = await db.query<VetVisitRow>(
|
||||||
`SELECT id, bird_id, visited_on::text, clinic_name, reason, notes
|
`SELECT id, bird_id, visited_on::text, clinic_name, reason, notes
|
||||||
@@ -766,7 +1005,7 @@ export const deleteVetVisitForBird = async (visitId: string, birdId: string) =>
|
|||||||
|
|
||||||
export const listMedicationsForBird = async (birdId: string, workspaceId: number) => {
|
export const listMedicationsForBird = async (birdId: string, workspaceId: number) => {
|
||||||
const result = await db.query<MedicationRow>(
|
const result = await db.query<MedicationRow>(
|
||||||
`SELECT id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes
|
`SELECT id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes, reminders_enabled
|
||||||
FROM medications
|
FROM medications
|
||||||
WHERE bird_id = $1
|
WHERE bird_id = $1
|
||||||
AND EXISTS (
|
AND EXISTS (
|
||||||
@@ -792,12 +1031,13 @@ export const createMedicationForBird = async (
|
|||||||
startDate: string,
|
startDate: string,
|
||||||
endDate: string | null,
|
endDate: string | null,
|
||||||
notes: string | null,
|
notes: string | null,
|
||||||
|
remindersEnabled: boolean,
|
||||||
) => {
|
) => {
|
||||||
const result = await db.query<MedicationRow>(
|
const result = await db.query<MedicationRow>(
|
||||||
`INSERT INTO medications (bird_id, name, dosage, frequency, dose_schedule, route, start_date, end_date, notes)
|
`INSERT INTO medications (bird_id, name, dosage, frequency, dose_schedule, route, start_date, end_date, notes, reminders_enabled)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes`,
|
RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes, reminders_enabled`,
|
||||||
[birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, notes],
|
[birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, notes, remindersEnabled],
|
||||||
);
|
);
|
||||||
|
|
||||||
return result.rows[0] ?? null;
|
return result.rows[0] ?? null;
|
||||||
@@ -814,6 +1054,7 @@ export const updateMedicationForBird = async (
|
|||||||
startDate: string,
|
startDate: string,
|
||||||
endDate: string | null,
|
endDate: string | null,
|
||||||
notes: string | null,
|
notes: string | null,
|
||||||
|
remindersEnabled: boolean,
|
||||||
) => {
|
) => {
|
||||||
const result = await db.query<MedicationRow>(
|
const result = await db.query<MedicationRow>(
|
||||||
`UPDATE medications
|
`UPDATE medications
|
||||||
@@ -824,11 +1065,12 @@ export const updateMedicationForBird = async (
|
|||||||
route = $7,
|
route = $7,
|
||||||
start_date = $8,
|
start_date = $8,
|
||||||
end_date = $9,
|
end_date = $9,
|
||||||
notes = $10
|
notes = $10,
|
||||||
|
reminders_enabled = $11
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND bird_id = $2
|
AND bird_id = $2
|
||||||
RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes`,
|
RETURNING id, bird_id, name, dosage, frequency, dose_schedule, route, start_date::text, end_date::text, notes, reminders_enabled`,
|
||||||
[medicationId, birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, notes],
|
[medicationId, birdId, name, dosage, frequency, JSON.stringify(doseSchedule), route, startDate, endDate, notes, remindersEnabled],
|
||||||
);
|
);
|
||||||
|
|
||||||
return result.rows[0] ?? null;
|
return result.rows[0] ?? null;
|
||||||
|
|||||||
@@ -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,6 +3,7 @@ import test from 'node:test';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
createWorkspace,
|
createWorkspace,
|
||||||
|
deleteWorkspaceMember,
|
||||||
deleteWorkspaceIfEmpty,
|
deleteWorkspaceIfEmpty,
|
||||||
ensureDefaultWorkspaceForUser,
|
ensureDefaultWorkspaceForUser,
|
||||||
ensurePersonalWorkspaceForUser,
|
ensurePersonalWorkspaceForUser,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
getPlatformAdminSummary,
|
getPlatformAdminSummary,
|
||||||
listOwnedWorkspacesByOwnerEmail,
|
listOwnedWorkspacesByOwnerEmail,
|
||||||
updateWorkspace,
|
updateWorkspace,
|
||||||
|
updateWorkspaceMemberRole,
|
||||||
} from './workspaceRepository.js';
|
} from './workspaceRepository.js';
|
||||||
import { mockDb } from '../test/mockDb.js';
|
import { mockDb } from '../test/mockDb.js';
|
||||||
import type { UserRow } from '../types.js';
|
import type { UserRow } from '../types.js';
|
||||||
@@ -259,6 +261,263 @@ test('listOwnedWorkspacesByOwnerEmail resolves accepted owner flocks by email',
|
|||||||
assert.match(calls[0].text, /workspaces\.id <> \$2/);
|
assert.match(calls[0].text, /workspaces\.id <> \$2/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('updateWorkspaceMemberRole changes a non-owner member role', async () => {
|
||||||
|
const { calls } = mockDb({
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: 'member-1',
|
||||||
|
workspace_id: 42,
|
||||||
|
user_id: 'user-2',
|
||||||
|
invite_email: 'helper@example.com',
|
||||||
|
name: 'Helper',
|
||||||
|
role: 'viewer',
|
||||||
|
accepted_at: '2026-04-14T00:00:00.000Z',
|
||||||
|
created_at: '2026-04-14T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const member = await updateWorkspaceMemberRole({
|
||||||
|
memberId: 'member-1',
|
||||||
|
workspaceId: 42,
|
||||||
|
role: 'viewer',
|
||||||
|
requesterMemberId: 'owner-member',
|
||||||
|
requesterIsBillingOwner: false,
|
||||||
|
requesterRole: 'owner',
|
||||||
|
billingEmail: 'billing@example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(member?.role, 'viewer');
|
||||||
|
assert.deepEqual(calls[0].params, ['member-1', 42, 'viewer', false, 'owner-member', 'billing@example.com', 'owner']);
|
||||||
|
assert.match(calls[0].text, /UPDATE workspace_members/);
|
||||||
|
assert.match(calls[0].text, /role <> 'owner'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateWorkspaceMemberRole returns null when no non-owner member matches', async () => {
|
||||||
|
mockDb({
|
||||||
|
rowCount: 0,
|
||||||
|
rows: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const member = await updateWorkspaceMemberRole({
|
||||||
|
memberId: 'owner-member',
|
||||||
|
workspaceId: 42,
|
||||||
|
role: 'viewer',
|
||||||
|
requesterMemberId: 'owner-member',
|
||||||
|
requesterIsBillingOwner: false,
|
||||||
|
requesterRole: 'owner',
|
||||||
|
billingEmail: 'billing@example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(member, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateWorkspaceMemberRole lets the billing owner change another owner role', async () => {
|
||||||
|
const { calls } = mockDb({
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: 'other-owner',
|
||||||
|
workspace_id: 42,
|
||||||
|
user_id: 'user-2',
|
||||||
|
invite_email: 'other@example.com',
|
||||||
|
name: 'Other Owner',
|
||||||
|
role: 'assistant',
|
||||||
|
accepted_at: '2026-04-14T00:00:00.000Z',
|
||||||
|
created_at: '2026-04-14T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const member = await updateWorkspaceMemberRole({
|
||||||
|
memberId: 'other-owner',
|
||||||
|
workspaceId: 42,
|
||||||
|
role: 'assistant',
|
||||||
|
requesterMemberId: 'billing-owner',
|
||||||
|
requesterIsBillingOwner: true,
|
||||||
|
requesterRole: 'owner',
|
||||||
|
billingEmail: 'billing@example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(member?.role, 'assistant');
|
||||||
|
assert.deepEqual(calls[0].params, ['other-owner', 42, 'assistant', true, 'billing-owner', 'billing@example.com', 'owner']);
|
||||||
|
assert.match(calls[0].text, /id <> \$5/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateWorkspaceMemberRole does not let the billing owner change their own owner role', async () => {
|
||||||
|
mockDb({
|
||||||
|
rowCount: 0,
|
||||||
|
rows: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const member = await updateWorkspaceMemberRole({
|
||||||
|
memberId: 'billing-owner',
|
||||||
|
workspaceId: 42,
|
||||||
|
role: 'assistant',
|
||||||
|
requesterMemberId: 'billing-owner',
|
||||||
|
requesterIsBillingOwner: true,
|
||||||
|
requesterRole: 'owner',
|
||||||
|
billingEmail: 'billing@example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(member, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateWorkspaceMemberRole lets a non-billing owner change another non-billing owner role', async () => {
|
||||||
|
const { calls } = mockDb({
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: 'other-owner',
|
||||||
|
workspace_id: 42,
|
||||||
|
user_id: 'user-2',
|
||||||
|
invite_email: 'other@example.com',
|
||||||
|
name: 'Other Owner',
|
||||||
|
role: 'assistant',
|
||||||
|
accepted_at: '2026-04-14T00:00:00.000Z',
|
||||||
|
created_at: '2026-04-14T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const member = await updateWorkspaceMemberRole({
|
||||||
|
memberId: 'other-owner',
|
||||||
|
workspaceId: 42,
|
||||||
|
role: 'assistant',
|
||||||
|
requesterMemberId: 'non-billing-owner',
|
||||||
|
requesterIsBillingOwner: false,
|
||||||
|
requesterRole: 'owner',
|
||||||
|
billingEmail: 'billing@example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(member?.role, 'assistant');
|
||||||
|
assert.deepEqual(calls[0].params, ['other-owner', 42, 'assistant', false, 'non-billing-owner', 'billing@example.com', 'owner']);
|
||||||
|
assert.match(calls[0].text, /LOWER\(BTRIM\(COALESCE\(invite_email, email\)\)\) <> LOWER\(BTRIM\(\$6\)\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateWorkspaceMemberRole does not let a non-billing owner change the billing owner role', async () => {
|
||||||
|
mockDb({
|
||||||
|
rowCount: 0,
|
||||||
|
rows: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const member = await updateWorkspaceMemberRole({
|
||||||
|
memberId: 'billing-owner',
|
||||||
|
workspaceId: 42,
|
||||||
|
role: 'assistant',
|
||||||
|
requesterMemberId: 'non-billing-owner',
|
||||||
|
requesterIsBillingOwner: false,
|
||||||
|
requesterRole: 'owner',
|
||||||
|
billingEmail: 'billing@example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(member, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateWorkspaceMemberRole lets the billing owner promote a non-owner to owner', async () => {
|
||||||
|
const { calls } = mockDb({
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: 'member-1',
|
||||||
|
workspace_id: 42,
|
||||||
|
user_id: 'user-2',
|
||||||
|
invite_email: 'helper@example.com',
|
||||||
|
name: 'Helper',
|
||||||
|
role: 'owner',
|
||||||
|
accepted_at: '2026-04-14T00:00:00.000Z',
|
||||||
|
created_at: '2026-04-14T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const member = await updateWorkspaceMemberRole({
|
||||||
|
memberId: 'member-1',
|
||||||
|
workspaceId: 42,
|
||||||
|
role: 'owner',
|
||||||
|
requesterMemberId: 'billing-owner',
|
||||||
|
requesterIsBillingOwner: true,
|
||||||
|
requesterRole: 'owner',
|
||||||
|
billingEmail: 'billing@example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(member?.role, 'owner');
|
||||||
|
assert.deepEqual(calls[0].params, ['member-1', 42, 'owner', true, 'billing-owner', 'billing@example.com', 'owner']);
|
||||||
|
assert.match(calls[0].text, /\$3 <> 'owner'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateWorkspaceMemberRole does not let a non-billing owner promote a member to owner', async () => {
|
||||||
|
mockDb({
|
||||||
|
rowCount: 0,
|
||||||
|
rows: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const member = await updateWorkspaceMemberRole({
|
||||||
|
memberId: 'member-1',
|
||||||
|
workspaceId: 42,
|
||||||
|
role: 'owner',
|
||||||
|
requesterMemberId: 'non-billing-owner',
|
||||||
|
requesterIsBillingOwner: false,
|
||||||
|
requesterRole: 'owner',
|
||||||
|
billingEmail: 'billing@example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(member, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleteWorkspaceMember removes non-owner members without billing owner access', async () => {
|
||||||
|
const { calls } = mockDb({
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [{ id: 'member-1' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleted = await deleteWorkspaceMember({
|
||||||
|
memberId: 'member-1',
|
||||||
|
workspaceId: 42,
|
||||||
|
requesterMemberId: 'owner-member',
|
||||||
|
requesterIsBillingOwner: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(deleted, true);
|
||||||
|
assert.deepEqual(calls[0].params, ['member-1', 42, false, 'owner-member']);
|
||||||
|
assert.match(calls[0].text, /role <> 'owner'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleteWorkspaceMember lets the billing owner remove another owner', async () => {
|
||||||
|
const { calls } = mockDb({
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [{ id: 'other-owner' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleted = await deleteWorkspaceMember({
|
||||||
|
memberId: 'other-owner',
|
||||||
|
workspaceId: 42,
|
||||||
|
requesterMemberId: 'billing-owner',
|
||||||
|
requesterIsBillingOwner: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(deleted, true);
|
||||||
|
assert.deepEqual(calls[0].params, ['other-owner', 42, true, 'billing-owner']);
|
||||||
|
assert.match(calls[0].text, /id <> \$4/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleteWorkspaceMember does not let the billing owner remove their own owner membership', async () => {
|
||||||
|
mockDb({
|
||||||
|
rowCount: 0,
|
||||||
|
rows: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleted = await deleteWorkspaceMember({
|
||||||
|
memberId: 'billing-owner',
|
||||||
|
workspaceId: 42,
|
||||||
|
requesterMemberId: 'billing-owner',
|
||||||
|
requesterIsBillingOwner: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(deleted, false);
|
||||||
|
});
|
||||||
|
|
||||||
test('getPlatformAdminSummary counts memorialized birds separately', async () => {
|
test('getPlatformAdminSummary counts memorialized birds separately', async () => {
|
||||||
const { calls } = mockDb({
|
const { calls } = mockDb({
|
||||||
rowCount: 1,
|
rowCount: 1,
|
||||||
|
|||||||
@@ -364,19 +364,81 @@ export const upsertWorkspaceMember = async ({
|
|||||||
return result.rows[0] ?? null;
|
return result.rows[0] ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteWorkspaceMember = async (memberId: string, workspaceId: number) => {
|
export const deleteWorkspaceMember = async ({
|
||||||
|
memberId,
|
||||||
|
workspaceId,
|
||||||
|
requesterMemberId,
|
||||||
|
requesterIsBillingOwner,
|
||||||
|
}: {
|
||||||
|
memberId: string;
|
||||||
|
workspaceId: number;
|
||||||
|
requesterMemberId: string;
|
||||||
|
requesterIsBillingOwner: boolean;
|
||||||
|
}) => {
|
||||||
const result = await db.query<{ id: string }>(
|
const result = await db.query<{ id: string }>(
|
||||||
`DELETE FROM workspace_members
|
`DELETE FROM workspace_members
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND workspace_id = $2
|
AND workspace_id = $2
|
||||||
AND role <> 'owner'
|
AND (
|
||||||
|
role <> 'owner'
|
||||||
|
OR (
|
||||||
|
$3 = TRUE
|
||||||
|
AND id <> $4
|
||||||
|
)
|
||||||
|
)
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[memberId, workspaceId],
|
[memberId, workspaceId, requesterIsBillingOwner, requesterMemberId],
|
||||||
);
|
);
|
||||||
|
|
||||||
return Boolean(result.rowCount);
|
return Boolean(result.rowCount);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const updateWorkspaceMemberRole = async ({
|
||||||
|
memberId,
|
||||||
|
workspaceId,
|
||||||
|
role,
|
||||||
|
requesterMemberId,
|
||||||
|
requesterIsBillingOwner,
|
||||||
|
requesterRole,
|
||||||
|
billingEmail,
|
||||||
|
}: {
|
||||||
|
memberId: string;
|
||||||
|
workspaceId: number;
|
||||||
|
role: WorkspaceMemberRow['role'];
|
||||||
|
requesterMemberId: string;
|
||||||
|
requesterIsBillingOwner: boolean;
|
||||||
|
requesterRole: WorkspaceMemberRow['role'];
|
||||||
|
billingEmail: string;
|
||||||
|
}) => {
|
||||||
|
const result = await db.query<WorkspaceMemberRow>(
|
||||||
|
`UPDATE workspace_members
|
||||||
|
SET role = $3
|
||||||
|
WHERE id = $1
|
||||||
|
AND workspace_id = $2
|
||||||
|
AND (
|
||||||
|
$3 <> 'owner'
|
||||||
|
OR $4 = TRUE
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
role <> 'owner'
|
||||||
|
OR (
|
||||||
|
id <> $5
|
||||||
|
AND (
|
||||||
|
$4 = TRUE
|
||||||
|
OR (
|
||||||
|
$7 = 'owner'
|
||||||
|
AND LOWER(BTRIM(COALESCE(invite_email, email))) <> LOWER(BTRIM($6))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
RETURNING id, workspace_id, user_id, COALESCE(invite_email, email) AS invite_email, name, role, accepted_at::text, created_at`,
|
||||||
|
[memberId, workspaceId, role, requesterIsBillingOwner, requesterMemberId, billingEmail, requesterRole],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
export const listRescueWorkspacesForAdmin = async () => {
|
export const listRescueWorkspacesForAdmin = async () => {
|
||||||
const result = await db.query<
|
const result = await db.query<
|
||||||
WorkspaceRow & {
|
WorkspaceRow & {
|
||||||
|
|||||||
+71
-29
@@ -13,38 +13,9 @@ export type UserRow = {
|
|||||||
email: string;
|
email: string;
|
||||||
password_hash: string | null;
|
password_hash: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
education_opt_out?: boolean;
|
|
||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DailyEducationQuestion = {
|
|
||||||
prompt: string;
|
|
||||||
options: string[];
|
|
||||||
correctAnswerIndex: number;
|
|
||||||
explanation: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DailyEducationRow = {
|
|
||||||
id: string;
|
|
||||||
publish_date: string;
|
|
||||||
fact: string;
|
|
||||||
quiz_questions: DailyEducationQuestion[];
|
|
||||||
created_by_user_id: string | null;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type EducationQuestionRow = {
|
|
||||||
id: string;
|
|
||||||
prompt: string;
|
|
||||||
options: string[];
|
|
||||||
correct_answer_index: number;
|
|
||||||
explanation: string | null;
|
|
||||||
created_by_user_id: string | null;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WorkspaceRow = {
|
export type WorkspaceRow = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -130,6 +101,10 @@ export type BirdRow = {
|
|||||||
motivators: string | null;
|
motivators: string | null;
|
||||||
demotivators: string | null;
|
demotivators: string | null;
|
||||||
favorite_snack: string | null;
|
favorite_snack: string | null;
|
||||||
|
vet_clinic_name: string | null;
|
||||||
|
vet_clinic_address: string | null;
|
||||||
|
vet_account_number: string | null;
|
||||||
|
vet_doctor_name: string | null;
|
||||||
gender: BirdGender;
|
gender: BirdGender;
|
||||||
date_of_birth: string | null;
|
date_of_birth: string | null;
|
||||||
gotcha_day: string | null;
|
gotcha_day: string | null;
|
||||||
@@ -187,6 +162,18 @@ export type PendingBirdTransferRow = {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type BirdTransferCodeRow = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
bird_id: string;
|
||||||
|
source_workspace_id: number;
|
||||||
|
requested_by_user_id: string;
|
||||||
|
completed_at: string | null;
|
||||||
|
completed_workspace_id: number | null;
|
||||||
|
revoked_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type WeightRow = {
|
export type WeightRow = {
|
||||||
id: string;
|
id: string;
|
||||||
bird_id: string;
|
bird_id: string;
|
||||||
@@ -215,6 +202,7 @@ export type MedicationRow = {
|
|||||||
start_date: string;
|
start_date: string;
|
||||||
end_date: string | null;
|
end_date: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
|
reminders_enabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MedicationDoseScheduleItem = {
|
export type MedicationDoseScheduleItem = {
|
||||||
@@ -223,6 +211,33 @@ export type MedicationDoseScheduleItem = {
|
|||||||
time: string;
|
time: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MedicationReminderCandidateRow = BirdRow & {
|
||||||
|
workspace_name: string;
|
||||||
|
medication_id: string;
|
||||||
|
medication_name: string;
|
||||||
|
dosage: string;
|
||||||
|
frequency: string;
|
||||||
|
dose_schedule: MedicationDoseScheduleItem[];
|
||||||
|
route: string | null;
|
||||||
|
medication_start_date: string;
|
||||||
|
medication_end_date: string | null;
|
||||||
|
medication_notes: string | null;
|
||||||
|
scheduled_on: string;
|
||||||
|
administration_slot: string;
|
||||||
|
administration_label: string;
|
||||||
|
administration_time: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MedicationReminderDeliveryRow = {
|
||||||
|
id: string;
|
||||||
|
medication_id: string;
|
||||||
|
bird_id: string;
|
||||||
|
workspace_id: number;
|
||||||
|
scheduled_on: string;
|
||||||
|
administration_slot: string;
|
||||||
|
delivered_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type MedicationAdministrationRow = {
|
export type MedicationAdministrationRow = {
|
||||||
id: string;
|
id: string;
|
||||||
medication_id: string;
|
medication_id: string;
|
||||||
@@ -235,6 +250,33 @@ export type MedicationAdministrationRow = {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FlockNoteRow = {
|
||||||
|
id: string;
|
||||||
|
workspace_id: number;
|
||||||
|
bird_id: string | null;
|
||||||
|
bird_name: string | null;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
created_by_user_id: string | null;
|
||||||
|
created_by_name: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuditLogEntryRow = {
|
||||||
|
id: string;
|
||||||
|
workspace_id: number;
|
||||||
|
user_id: string | null;
|
||||||
|
actor_name: string | null;
|
||||||
|
actor_email: string | null;
|
||||||
|
action: string;
|
||||||
|
entity_type: string;
|
||||||
|
entity_id: string | null;
|
||||||
|
entity_name: string | null;
|
||||||
|
details: Record<string, unknown>;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type AuthContext = {
|
export type AuthContext = {
|
||||||
user: UserRow;
|
user: UserRow;
|
||||||
session: AuthSessionRow;
|
session: AuthSessionRow;
|
||||||
|
|||||||
+64
-1
@@ -2,16 +2,36 @@ import { Worker } from 'bullmq';
|
|||||||
|
|
||||||
import { ensureSchema } from './db/schema.js';
|
import { ensureSchema } from './db/schema.js';
|
||||||
import { db } from './db/client.js';
|
import { db } from './db/client.js';
|
||||||
import { runBirdMilestoneReminders, startBirdMilestoneReminderScheduler } from './app.js';
|
import {
|
||||||
|
runBirdMilestoneReminders,
|
||||||
|
runMedicationReminders,
|
||||||
|
startBirdMilestoneReminderScheduler,
|
||||||
|
startMedicationReminderScheduler,
|
||||||
|
} from './app.js';
|
||||||
|
import {
|
||||||
|
adoptionReportQueueName,
|
||||||
|
closeAdoptionReportQueue,
|
||||||
|
type AdoptionReportJobData,
|
||||||
|
type AdoptionReportJobResult,
|
||||||
|
} from './queues/adoptionReportQueue.js';
|
||||||
import {
|
import {
|
||||||
birdMilestoneReminderQueueName,
|
birdMilestoneReminderQueueName,
|
||||||
closeBirdMilestoneReminderQueue,
|
closeBirdMilestoneReminderQueue,
|
||||||
type BirdMilestoneReminderJobData,
|
type BirdMilestoneReminderJobData,
|
||||||
type BirdMilestoneReminderJobResult,
|
type BirdMilestoneReminderJobResult,
|
||||||
} from './queues/birdMilestoneReminderQueue.js';
|
} from './queues/birdMilestoneReminderQueue.js';
|
||||||
|
import {
|
||||||
|
closeMedicationReminderQueue,
|
||||||
|
medicationReminderQueueName,
|
||||||
|
type MedicationReminderJobData,
|
||||||
|
type MedicationReminderJobResult,
|
||||||
|
} from './queues/medicationReminderQueue.js';
|
||||||
import { redisConnection } from './queues/redisConnection.js';
|
import { redisConnection } from './queues/redisConnection.js';
|
||||||
|
import { renderAdoptionReportForBird } from './reports/adoptionReportJob.js';
|
||||||
|
|
||||||
let birdMilestoneWorker: Worker<BirdMilestoneReminderJobData, BirdMilestoneReminderJobResult> | null = null;
|
let birdMilestoneWorker: Worker<BirdMilestoneReminderJobData, BirdMilestoneReminderJobResult> | null = null;
|
||||||
|
let medicationReminderWorker: Worker<MedicationReminderJobData, MedicationReminderJobResult> | null = null;
|
||||||
|
let adoptionReportWorker: Worker<AdoptionReportJobData, AdoptionReportJobResult> | null = null;
|
||||||
|
|
||||||
const startWorker = async () => {
|
const startWorker = async () => {
|
||||||
await ensureSchema();
|
await ensureSchema();
|
||||||
@@ -35,14 +55,57 @@ const startWorker = async () => {
|
|||||||
console.error(`Bird milestone reminder job failed: id=${job?.id ?? 'unknown'}`, error);
|
console.error(`Bird milestone reminder job failed: id=${job?.id ?? 'unknown'}`, error);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
medicationReminderWorker = new Worker<MedicationReminderJobData, MedicationReminderJobResult>(
|
||||||
|
medicationReminderQueueName,
|
||||||
|
async (job) => {
|
||||||
|
const result = await runMedicationReminders(job.data.runDate, job.data.currentTime);
|
||||||
|
console.log(
|
||||||
|
`Medication reminder job completed for ${result.runDate} ${result.currentTime}: checked=${result.checked}, sent=${result.sent}, skipped=${result.skipped}, failed=${result.failed}`,
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
connection: redisConnection,
|
||||||
|
concurrency: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
medicationReminderWorker.on('failed', (job, error) => {
|
||||||
|
console.error(`Medication reminder job failed: id=${job?.id ?? 'unknown'}`, error);
|
||||||
|
});
|
||||||
|
|
||||||
|
adoptionReportWorker = new Worker<AdoptionReportJobData, AdoptionReportJobResult>(
|
||||||
|
adoptionReportQueueName,
|
||||||
|
async (job) => {
|
||||||
|
const pdf = await renderAdoptionReportForBird(job.data);
|
||||||
|
console.log(`Adoption report job completed: id=${job.id ?? 'unknown'}, birdId=${job.data.birdId}, bytes=${pdf.length}`);
|
||||||
|
return {
|
||||||
|
pdfBase64: pdf.toString('base64'),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{
|
||||||
|
connection: redisConnection,
|
||||||
|
concurrency: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
adoptionReportWorker.on('failed', (job, error) => {
|
||||||
|
console.error(`Adoption report job failed: id=${job?.id ?? 'unknown'}, birdId=${job?.data.birdId ?? 'unknown'}`, error);
|
||||||
|
});
|
||||||
|
|
||||||
startBirdMilestoneReminderScheduler();
|
startBirdMilestoneReminderScheduler();
|
||||||
|
startMedicationReminderScheduler();
|
||||||
console.log('FlockPal worker started.');
|
console.log('FlockPal worker started.');
|
||||||
};
|
};
|
||||||
|
|
||||||
const shutdown = async (signal: string) => {
|
const shutdown = async (signal: string) => {
|
||||||
console.log(`FlockPal worker received ${signal}; shutting down.`);
|
console.log(`FlockPal worker received ${signal}; shutting down.`);
|
||||||
await birdMilestoneWorker?.close();
|
await birdMilestoneWorker?.close();
|
||||||
|
await medicationReminderWorker?.close();
|
||||||
|
await adoptionReportWorker?.close();
|
||||||
await closeBirdMilestoneReminderQueue();
|
await closeBirdMilestoneReminderQueue();
|
||||||
|
await closeMedicationReminderQueue();
|
||||||
|
await closeAdoptionReportQueue();
|
||||||
await db.close();
|
await db.close();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ services:
|
|||||||
POSTGRES_USER: ${POSTGRES_USER:-flockpal}
|
POSTGRES_USER: ${POSTGRES_USER:-flockpal}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD for production}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD for production}
|
||||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
||||||
|
ADOPTION_REPORT_RENDER_TIMEOUT_MS: ${ADOPTION_REPORT_RENDER_TIMEOUT_MS:-45000}
|
||||||
IMAGE_STORAGE_PROVIDER: ${IMAGE_STORAGE_PROVIDER:-database}
|
IMAGE_STORAGE_PROVIDER: ${IMAGE_STORAGE_PROVIDER:-database}
|
||||||
S3_ENDPOINT: ${S3_ENDPOINT:-}
|
S3_ENDPOINT: ${S3_ENDPOINT:-}
|
||||||
S3_REGION: ${S3_REGION:-}
|
S3_REGION: ${S3_REGION:-}
|
||||||
@@ -58,6 +59,7 @@ services:
|
|||||||
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
||||||
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
||||||
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
||||||
|
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
|
||||||
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
||||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
||||||
@@ -94,6 +96,12 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "dist/healthcheck.js", "api-ready"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
- traefik.docker.network=traefik
|
- traefik.docker.network=traefik
|
||||||
@@ -138,6 +146,7 @@ services:
|
|||||||
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
||||||
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
||||||
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
||||||
|
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
|
||||||
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
||||||
SMTP_HOST: ${SMTP_HOST:-}
|
SMTP_HOST: ${SMTP_HOST:-}
|
||||||
SMTP_PORT: ${SMTP_PORT:-587}
|
SMTP_PORT: ${SMTP_PORT:-587}
|
||||||
@@ -151,6 +160,12 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "dist/healthcheck.js", "worker"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
@@ -162,6 +177,12 @@ services:
|
|||||||
container_name: flockpal-frontend
|
container_name: flockpal-frontend
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/healthz"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
- traefik.docker.network=traefik
|
- traefik.docker.network=traefik
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ services:
|
|||||||
POSTGRES_USER: ${POSTGRES_USER:-flockpal}
|
POSTGRES_USER: ${POSTGRES_USER:-flockpal}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flockpal_dev_password}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flockpal_dev_password}
|
||||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
||||||
|
ADOPTION_REPORT_RENDER_TIMEOUT_MS: ${ADOPTION_REPORT_RENDER_TIMEOUT_MS:-45000}
|
||||||
IMAGE_STORAGE_PROVIDER: ${IMAGE_STORAGE_PROVIDER:-database}
|
IMAGE_STORAGE_PROVIDER: ${IMAGE_STORAGE_PROVIDER:-database}
|
||||||
S3_ENDPOINT: ${S3_ENDPOINT:-}
|
S3_ENDPOINT: ${S3_ENDPOINT:-}
|
||||||
S3_REGION: ${S3_REGION:-}
|
S3_REGION: ${S3_REGION:-}
|
||||||
@@ -56,6 +57,7 @@ services:
|
|||||||
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
||||||
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
||||||
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
||||||
|
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
|
||||||
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
||||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
||||||
@@ -131,6 +133,7 @@ services:
|
|||||||
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
||||||
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
|
||||||
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
||||||
|
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
|
||||||
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
||||||
SMTP_HOST: ${SMTP_HOST:-}
|
SMTP_HOST: ${SMTP_HOST:-}
|
||||||
SMTP_PORT: ${SMTP_PORT:-587}
|
SMTP_PORT: ${SMTP_PORT:-587}
|
||||||
|
|||||||
+78
-3
@@ -208,6 +208,10 @@ Role requirements are called out per endpoint below. If the signed-in member lac
|
|||||||
"name": "Kiwi",
|
"name": "Kiwi",
|
||||||
"tagId": "FP-001",
|
"tagId": "FP-001",
|
||||||
"species": "Cockatiel",
|
"species": "Cockatiel",
|
||||||
|
"vetClinicName": "Avian Care Center",
|
||||||
|
"vetClinicAddress": "123 Feather Lane, Raleigh, NC",
|
||||||
|
"vetAccountNumber": "FP-1001",
|
||||||
|
"vetDoctorName": "Dr. Rivera",
|
||||||
"gender": "female",
|
"gender": "female",
|
||||||
"dateOfBirth": "2023-05-10",
|
"dateOfBirth": "2023-05-10",
|
||||||
"gotchaDay": "2023-08-21",
|
"gotchaDay": "2023-08-21",
|
||||||
@@ -315,14 +319,47 @@ Validation failures return `400` with this shape:
|
|||||||
|
|
||||||
#### `GET /api/health`
|
#### `GET /api/health`
|
||||||
|
|
||||||
Public health check.
|
Public readiness-compatible health check. Verifies backend dependencies.
|
||||||
|
|
||||||
Response `200`:
|
Response `200`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "ok": true }
|
{
|
||||||
|
"ok": true,
|
||||||
|
"service": "flockpal-backend",
|
||||||
|
"status": "ready",
|
||||||
|
"checkedAt": "2026-06-06T00:00:00.000Z",
|
||||||
|
"dependencies": {
|
||||||
|
"postgres": { "ok": true, "latencyMs": 3 },
|
||||||
|
"redis": { "ok": true, "latencyMs": 4 }
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Response `503` when Postgres or Redis is unavailable.
|
||||||
|
|
||||||
|
#### `GET /api/health/live`
|
||||||
|
|
||||||
|
Public liveness check. Verifies the backend process is running without checking dependencies.
|
||||||
|
|
||||||
|
Response `200`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"service": "flockpal-backend",
|
||||||
|
"status": "live",
|
||||||
|
"uptimeSeconds": 120,
|
||||||
|
"checkedAt": "2026-06-06T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `GET /api/health/ready`
|
||||||
|
|
||||||
|
Public readiness check. Verifies the backend can reach Postgres and Redis.
|
||||||
|
|
||||||
|
Response `200` uses the same shape as `GET /api/health`; response `503` means at least one dependency failed.
|
||||||
|
|
||||||
### Metrics
|
### Metrics
|
||||||
|
|
||||||
#### `GET /api/metrics`
|
#### `GET /api/metrics`
|
||||||
@@ -793,6 +830,10 @@ Request body:
|
|||||||
"name": "Kiwi",
|
"name": "Kiwi",
|
||||||
"tagId": "FP-001",
|
"tagId": "FP-001",
|
||||||
"species": "Cockatiel",
|
"species": "Cockatiel",
|
||||||
|
"vetClinicName": "Avian Care Center",
|
||||||
|
"vetClinicAddress": "123 Feather Lane, Raleigh, NC",
|
||||||
|
"vetAccountNumber": "FP-1001",
|
||||||
|
"vetDoctorName": "Dr. Rivera",
|
||||||
"gender": "female",
|
"gender": "female",
|
||||||
"dateOfBirth": "2023-05-10",
|
"dateOfBirth": "2023-05-10",
|
||||||
"gotchaDay": "2023-08-21",
|
"gotchaDay": "2023-08-21",
|
||||||
@@ -805,7 +846,7 @@ Request body:
|
|||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- `dateOfBirth`, `gotchaDay`, and `photoDataUrl` may be omitted or sent as empty strings
|
- `dateOfBirth`, `gotchaDay`, `photoDataUrl`, and veterinary info fields may be omitted or sent as empty strings
|
||||||
- `chartColor` defaults to `#cb3a35`
|
- `chartColor` defaults to `#cb3a35`
|
||||||
|
|
||||||
Response `201`:
|
Response `201`:
|
||||||
@@ -889,6 +930,40 @@ Possible errors:
|
|||||||
- `409` if that owner email owns more than one receiving flock
|
- `409` if that owner email owns more than one receiving flock
|
||||||
- `409` if the destination flock already has a bird using the same `tagId`
|
- `409` if the destination flock already has a bird using the same `tagId`
|
||||||
|
|
||||||
|
#### `POST /api/birds/:birdId/transfer-code`
|
||||||
|
|
||||||
|
Requires a browser session, write access, and role `owner` or `assistant`. Creates a unique transfer code for a bird. Creating a new open code for the same bird revokes earlier unused codes for that bird.
|
||||||
|
|
||||||
|
Response `201`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"transferCode": {
|
||||||
|
"code": "secure-code",
|
||||||
|
"bird": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `POST /api/bird-transfer-codes/:code/accept`
|
||||||
|
|
||||||
|
Requires a browser session, write access, and role `owner` or `assistant`. Accepts a transfer code into the signed-in user's active flock.
|
||||||
|
|
||||||
|
Response `200`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"bird": {},
|
||||||
|
"sourceWorkspaceName": "Previous Flock",
|
||||||
|
"workspace": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Possible errors:
|
||||||
|
|
||||||
|
- `404` if the code does not exist, was revoked, was already used, or the bird is no longer available
|
||||||
|
- `409` if the bird is already in the active flock or the active flock already has the same `tagId`
|
||||||
|
|
||||||
#### `DELETE /api/birds/:birdId`
|
#### `DELETE /api/birds/:birdId`
|
||||||
|
|
||||||
Requires auth with write access and role `owner`, `assistant`, or `caregiver`. Deletes a bird.
|
Requires auth with write access and role `owner`, `assistant`, or `caregiver`. Deletes a bird.
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ server {
|
|||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
|
||||||
|
location = /healthz {
|
||||||
|
access_log off;
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
return 200 "ok\n";
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|||||||
+1593
-775
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 242 KiB |
+181
-126
@@ -713,6 +713,11 @@ textarea {
|
|||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bird-detail-panel {
|
||||||
|
margin-right: 3rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.flock-detail-column {
|
.flock-detail-column {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
@@ -745,123 +750,6 @@ textarea {
|
|||||||
align-content: start;
|
align-content: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.daily-education-panel,
|
|
||||||
.daily-quiz,
|
|
||||||
.quiz-options,
|
|
||||||
.education-question-editor {
|
|
||||||
display: grid;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.daily-education-panel.condensed {
|
|
||||||
gap: 0.35rem;
|
|
||||||
padding-block: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.daily-education-panel.condensed .panel-header {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.daily-education-teaser {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.daily-fact {
|
|
||||||
margin: 0;
|
|
||||||
padding: 1rem;
|
|
||||||
border-left: 4px solid var(--accent-gold);
|
|
||||||
border-radius: 0 8px 8px 0;
|
|
||||||
background: rgba(255, 254, 250, 0.7);
|
|
||||||
font-size: 1.08rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.daily-quiz {
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(min(290px, 100%), 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiz-question,
|
|
||||||
.quiz-editor-question {
|
|
||||||
min-width: 0;
|
|
||||||
margin: 0;
|
|
||||||
border: 1px solid var(--button-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiz-question {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.85rem;
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: rgba(255, 254, 250, 0.64);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiz-question legend,
|
|
||||||
.quiz-editor-question legend {
|
|
||||||
padding: 0 0.35rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiz-option {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
|
||||||
align-items: start;
|
|
||||||
gap: 0.65rem;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 0.7rem;
|
|
||||||
border: 1px solid rgba(39, 105, 179, 0.12);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: rgba(255, 255, 255, 0.58);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiz-option.correct {
|
|
||||||
border-color: rgba(35, 138, 90, 0.42);
|
|
||||||
background: rgba(223, 247, 229, 0.82);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiz-option.incorrect {
|
|
||||||
border-color: rgba(203, 58, 53, 0.36);
|
|
||||||
background: rgba(255, 236, 232, 0.82);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiz-option input {
|
|
||||||
width: auto;
|
|
||||||
margin: 0.25rem 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiz-feedback {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--accent-red);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiz-feedback.correct {
|
|
||||||
color: var(--accent-green);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-education-panel,
|
|
||||||
.education-admin-basics,
|
|
||||||
.quiz-editor-question,
|
|
||||||
.education-admin-list {
|
|
||||||
display: grid;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.education-admin-basics {
|
|
||||||
grid-template-columns: minmax(180px, 0.35fr) minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiz-editor-question {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiz-editor-options {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.education-admin-list span {
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-row {
|
.button-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
@@ -1196,6 +1084,35 @@ textarea {
|
|||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.note-card,
|
||||||
|
.audit-log-card {
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-card div,
|
||||||
|
.audit-log-card div {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-card span,
|
||||||
|
.audit-log-card span,
|
||||||
|
.audit-log-card small {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-card p {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-card .button-row {
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.legend-grid,
|
.legend-grid,
|
||||||
.detail-grid,
|
.detail-grid,
|
||||||
.summary-grid {
|
.summary-grid {
|
||||||
@@ -1207,17 +1124,22 @@ textarea {
|
|||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: auto 1fr;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 1rem;
|
padding: 1rem 6.4rem 1rem 1rem;
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
background: linear-gradient(180deg, rgba(255, 252, 247, 0.92), rgba(240, 248, 244, 0.84));
|
background: linear-gradient(180deg, rgba(255, 252, 247, 0.92), rgba(240, 248, 244, 0.84));
|
||||||
border: 1px solid rgba(39, 105, 179, 0.1);
|
border: 1px solid rgba(39, 105, 179, 0.1);
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr-profile-button {
|
.profile-actions {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.85rem;
|
top: 0.85rem;
|
||||||
right: 0.85rem;
|
right: 0.85rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-icon-button {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
width: 42px;
|
width: 42px;
|
||||||
@@ -1228,15 +1150,92 @@ textarea {
|
|||||||
box-shadow: 0 10px 20px rgba(86, 63, 34, 0.12);
|
box-shadow: 0 10px 20px rgba(86, 63, 34, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr-profile-button:hover {
|
.profile-icon-button:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
border-color: rgba(35, 138, 90, 0.34);
|
border-color: rgba(35, 138, 90, 0.34);
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr-profile-button svg {
|
.profile-icon-button svg {
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--accent-blue);
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-profile-button svg {
|
||||||
fill: var(--accent-blue);
|
fill: var(--accent-blue);
|
||||||
|
stroke: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-detail-tabs {
|
||||||
|
position: absolute;
|
||||||
|
top: 5rem;
|
||||||
|
right: -3rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-detail-tab {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 48px;
|
||||||
|
height: 44px;
|
||||||
|
border: 1px solid rgba(39, 105, 179, 0.14);
|
||||||
|
border-left: 0;
|
||||||
|
border-radius: 0 14px 14px 0;
|
||||||
|
background: rgba(255, 254, 250, 0.92);
|
||||||
|
color: var(--muted);
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease, color 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-detail-tab svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-detail-tab .weight-tab-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
fill: currentColor;
|
||||||
|
stroke: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-detail-tab .info-tab-icon,
|
||||||
|
.bird-detail-tab .note-tab-icon,
|
||||||
|
.bird-detail-tab .report-tab-icon,
|
||||||
|
.bird-detail-tab .audit-tab-icon,
|
||||||
|
.bird-detail-tab .vet-tab-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
fill: currentColor;
|
||||||
|
stroke: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-detail-tab:hover {
|
||||||
|
border-color: rgba(35, 138, 90, 0.28);
|
||||||
|
color: var(--ink);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-detail-tab.active {
|
||||||
|
border-color: rgba(35, 138, 90, 0.42);
|
||||||
|
background: rgba(240, 248, 244, 0.95);
|
||||||
|
color: var(--accent-green);
|
||||||
|
box-shadow: 10px 10px 20px rgba(39, 105, 179, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-detail-tab-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-copy {
|
.profile-copy {
|
||||||
@@ -1708,6 +1707,44 @@ label {
|
|||||||
accent-color: var(--accent-green);
|
accent-color: var(--accent-green);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.checkbox-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: min(100%, 420px);
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border: 1px solid rgba(53, 129, 98, 0.24);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.62);
|
||||||
|
box-shadow: 0 12px 24px rgba(86, 63, 34, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-row input[type="checkbox"] {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin: 0.1rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
accent-color: var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-row span {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.15rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-row strong {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 0.98rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-row small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
.primary-button {
|
.primary-button {
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
@@ -1966,11 +2003,6 @@ label {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
.education-admin-basics,
|
|
||||||
.quiz-editor-options {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-shell,
|
.app-shell,
|
||||||
.auth-panel,
|
.auth-panel,
|
||||||
.hero-card,
|
.hero-card,
|
||||||
@@ -2008,6 +2040,29 @@ label {
|
|||||||
justify-content: start;
|
justify-content: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bird-detail-panel {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-hero {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-detail-tabs {
|
||||||
|
position: static;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-detail-tab {
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
border-left: 1px solid rgba(39, 105, 179, 0.14);
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.side-rail {
|
.side-rail {
|
||||||
position: static;
|
position: static;
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
|||||||
Reference in New Issue
Block a user