36 Commits

Author SHA1 Message Date
Corey Blais 18fd76dc1f another fix of timeline fonts
Deploy / deploy-dev (push) Successful in 2m13s
Deploy / deploy-prod (push) Has been skipped
2026-07-01 21:05:23 -04:00
Corey Blais f627157a14 fixing timeline fonts in dev
Deploy / deploy-dev (push) Successful in 2m14s
Deploy / deploy-prod (push) Has been skipped
2026-07-01 18:16:43 -04:00
Corey Blais 35bd87b8b5 Updated timeline
Deploy / deploy-dev (push) Successful in 2m20s
Deploy / deploy-prod (push) Has been skipped
2026-07-01 17:52:40 -04:00
blaisadmin 14bc1c30a0 Merge origin/dev into dev
Deploy / deploy-dev (push) Successful in 3m48s
Deploy / deploy-prod (push) Has been skipped
2026-06-30 23:29:31 -04:00
blaisadmin d03672fcdd Fixed Timeline 2026-06-30 23:25:07 -04:00
Corey Blais 46e07336ef Fixing dev timeline icons
Deploy / deploy-dev (push) Successful in 2m12s
Deploy / deploy-prod (push) Has been skipped
2026-06-30 18:25:30 -04:00
Corey Blais bcaa8c4464 Fixing dev timeline icons
Deploy / deploy-dev (push) Successful in 2m15s
Deploy / deploy-prod (push) Has been skipped
2026-06-30 18:05:59 -04:00
Corey Blais 84d850a1ba Updated timeline
Deploy / deploy-dev (push) Successful in 2m42s
Deploy / deploy-prod (push) Has been skipped
2026-06-30 17:05:26 -04:00
Corey Blais 0dfacc0d17 Fixed timeline icon
Deploy / deploy-dev (push) Successful in 2m9s
Deploy / deploy-prod (push) Has been skipped
2026-06-30 12:16:31 -04:00
blaisadmin 7ef20ab0fb Added mapbox integration
Deploy / deploy-dev (push) Successful in 2m33s
Deploy / deploy-prod (push) Has been skipped
2026-06-29 19:50:08 -04:00
blaisadmin 9ddd85b5c4 working on timeline locations
Deploy / deploy-dev (push) Successful in 2m21s
Deploy / deploy-prod (push) Has been skipped
2026-06-28 22:57:22 -04:00
Corey Blais a988d9662b Added timeline feature
Deploy / deploy-dev (push) Successful in 2m42s
Deploy / deploy-prod (push) Has been skipped
2026-06-28 12:30:36 -04:00
blaisadmin 56068e02a3 Additional Genders
Deploy / deploy-dev (push) Successful in 3m17s
Deploy / deploy-prod (push) Has been skipped
2026-06-17 21:59:09 -04:00
Corey Blais 1140be8f32 weight edit fixes
Deploy / deploy-dev (push) Successful in 3m1s
Deploy / deploy-prod (push) Has been skipped
2026-06-16 15:28:01 -04:00
Corey Blais 4d3ab0b143 Updated weight edit
Deploy / deploy-dev (push) Successful in 2m1s
Deploy / deploy-prod (push) Has been skipped
2026-06-16 10:33:34 -04:00
Corey Blais 1e98d55cb5 trimmed weight edit
Deploy / deploy-dev (push) Successful in 2m42s
Deploy / deploy-prod (push) Has been skipped
2026-06-16 10:00:50 -04:00
Corey Blais 454adc6f5e adding weight edits
Deploy / deploy-dev (push) Successful in 3m0s
Deploy / deploy-prod (push) Has been skipped
2026-06-16 09:31:16 -04:00
blaisadmin ae8c4326b5 dev login fixes
Deploy / deploy-dev (push) Successful in 1m55s
Deploy / deploy-prod (push) Has been skipped
2026-06-05 22:12:19 -04:00
blaisadmin 480bbe8fc7 Adding promoting to owner
Deploy / deploy-dev (push) Successful in 2m26s
Deploy / deploy-prod (push) Has been skipped
2026-06-05 21:15:55 -04:00
blaisadmin bb589e3489 Adjusting role actions
Deploy / deploy-dev (push) Successful in 2m29s
Deploy / deploy-prod (push) Has been skipped
2026-06-05 21:09:31 -04:00
Corey Blais c3bec15c63 Medication reminder and pdr worker
Deploy / deploy-dev (push) Successful in 2m39s
Deploy / deploy-prod (push) Has been skipped
2026-06-03 15:21:21 -04:00
Corey Blais 979a17132d Fix adoption report photos and section order
Deploy / deploy-dev (push) Successful in 1m41s
Deploy / deploy-prod (push) Has been skipped
2026-06-02 18:26:51 -04:00
Corey Blais cadbdc2a7f Fix adoption report QR and chart layout
Deploy / deploy-dev (push) Successful in 1m40s
Deploy / deploy-prod (push) Has been skipped
2026-06-02 18:19:01 -04:00
Corey Blais 8e2f789e9b Refine adoption report header layout
Deploy / deploy-dev (push) Successful in 1m45s
Deploy / deploy-prod (push) Has been skipped
2026-06-02 18:10:13 -04:00
Corey Blais 7e2d06c50b Generate adoption reports as PDFs
Deploy / deploy-dev (push) Successful in 2m24s
Deploy / deploy-prod (push) Has been skipped
2026-06-02 17:49:31 -04:00
Corey Blais c2d518f864 Use fixed screen size for adoption report sheet
Deploy / deploy-dev (push) Successful in 1m32s
Deploy / deploy-prod (push) Has been skipped
2026-06-02 17:31:33 -04:00
Corey Blais c3297b5915 Wrap adoption report in letter sheet
Deploy / deploy-dev (push) Successful in 1m37s
Deploy / deploy-prod (push) Has been skipped
2026-06-02 17:24:32 -04:00
Corey Blais 41dda33310 Tighten adoption report page scale
Deploy / deploy-dev (push) Successful in 1m40s
Deploy / deploy-prod (push) Has been skipped
2026-06-02 17:17:21 -04:00
Corey Blais c98a5a2863 Size adoption report for letter paper
Deploy / deploy-dev (push) Successful in 1m38s
Deploy / deploy-prod (push) Has been skipped
2026-06-02 17:10:55 -04:00
Corey Blais 6c9017c3dc Stabilize adoption report layout
Deploy / deploy-dev (push) Successful in 1m41s
Deploy / deploy-prod (push) Has been skipped
2026-06-02 16:33:26 -04:00
Corey Blais 6b11a73579 fixing analytics icon pt2
Deploy / deploy-dev (push) Successful in 1m41s
Deploy / deploy-prod (push) Has been skipped
2026-06-02 12:28:34 -04:00
Corey Blais 1f26255ebd fixing analytics icon
Deploy / deploy-dev (push) Successful in 1m45s
Deploy / deploy-prod (push) Has been skipped
2026-06-02 12:22:14 -04:00
Corey Blais 14cdfe603d Updated adoption report
Deploy / deploy-dev (push) Successful in 2m0s
Deploy / deploy-prod (push) Has been skipped
2026-06-02 12:13:01 -04:00
Corey Blais d5bb87910e Added adoption report and transfer code
Deploy / deploy-dev (push) Successful in 2m31s
Deploy / deploy-prod (push) Has been skipped
2026-06-01 18:57:53 -04:00
Corey Blais 3053e3bef5 Added vet info
Deploy / deploy-dev (push) Successful in 3m0s
Deploy / deploy-prod (push) Has been skipped
2026-06-01 14:59:03 -04:00
blaisadmin 6ade13a8be Add flock member notes and audit tabs
Deploy / deploy-dev (push) Successful in 2m27s
Deploy / deploy-prod (push) Has been skipped
2026-05-30 22:30:35 -04:00
22 changed files with 3184 additions and 1149 deletions
+1
View File
@@ -14,6 +14,7 @@ PHOTO_DELIVERY_MODE=proxy
FRONTEND_URL=http://localhost:3000
BACKEND_URL=http://localhost:5000
VITE_API_BASE_URL=http://localhost:5000/api
MAPBOX_ACCESS_TOKEN=
NODE_ENV=development
TRUST_PROXY=
ADMIN_EMAILS=corey@blaishome.online
+2 -3
View File
@@ -3,14 +3,13 @@ name: Deploy
on:
push:
branches:
- main
- dev
- develop
workflow_dispatch:
jobs:
deploy-dev:
if: ${{ github.event_name == 'push' && (github.ref_name == 'dev' || github.ref_name == 'develop') }}
if: ${{ github.event_name == 'push' }}
runs-on: ubuntu-latest
container:
volumes:
@@ -49,7 +48,7 @@ jobs:
docker compose -f docker-compose.dev.yaml up -d --build
deploy-prod:
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref_name == 'main') }}
if: ${{ github.event_name == 'workflow_dispatch' }}
runs-on: ubuntu-latest
container:
volumes:
+1 -17
View File
@@ -47,23 +47,6 @@ The default `docker-compose.yml` is development-only. It mounts source files, in
## 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
Create a compressed Postgres backup from the Docker Compose Postgres service:
@@ -103,6 +86,7 @@ curl -H "Authorization: Bearer <admin-token>" https://your-host/api/metrics
- `FRONTEND_URL`
- `BACKEND_URL`
- `VITE_API_BASE_URL`
- `MAPBOX_ACCESS_TOKEN`
- `REDIS_URL`
- `IMAGE_STORAGE_PROVIDER`
- `S3_ENDPOINT`
+674 -197
View File
File diff suppressed because it is too large Load Diff
+69 -5
View File
@@ -30,6 +30,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ALTER TABLE workspaces
DROP CONSTRAINT IF EXISTS workspaces_id_check;
ALTER TABLE users
ADD COLUMN IF NOT EXISTS education_opt_out BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE workspaces
ADD COLUMN IF NOT EXISTS billing_email VARCHAR(255),
ADD COLUMN IF NOT EXISTS billing_plan VARCHAR(32) NOT NULL DEFAULT 'household_basic',
@@ -139,6 +142,37 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
CREATE INDEX IF NOT EXISTS idx_auth_sessions_created_user
ON auth_sessions (created_at DESC, user_id);
CREATE TABLE IF NOT EXISTS daily_education (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
publish_date DATE NOT NULL UNIQUE,
fact TEXT NOT NULL,
quiz_questions JSONB NOT NULL DEFAULT '[]'::jsonb,
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE daily_education
ALTER COLUMN quiz_questions SET DEFAULT '[]'::jsonb;
CREATE INDEX IF NOT EXISTS idx_daily_education_publish_date
ON daily_education (publish_date DESC);
CREATE TABLE IF NOT EXISTS education_question_bank (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
prompt VARCHAR(500) NOT NULL,
options JSONB NOT NULL,
correct_answer_index INTEGER NOT NULL,
explanation VARCHAR(800),
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CHECK (correct_answer_index >= 0 AND correct_answer_index <= 3)
);
CREATE INDEX IF NOT EXISTS idx_education_question_bank_created
ON education_question_bank (created_at DESC);
CREATE TABLE IF NOT EXISTS integration_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -215,6 +249,8 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
motivators VARCHAR(1000),
demotivators VARCHAR(1000),
favorite_snack VARCHAR(160),
location_label VARCHAR(160),
location_details JSONB,
vet_clinic_name VARCHAR(160),
vet_clinic_address VARCHAR(500),
vet_account_number VARCHAR(120),
@@ -243,6 +279,8 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ADD COLUMN IF NOT EXISTS motivators VARCHAR(1000),
ADD COLUMN IF NOT EXISTS demotivators VARCHAR(1000),
ADD COLUMN IF NOT EXISTS favorite_snack VARCHAR(160),
ADD COLUMN IF NOT EXISTS location_label VARCHAR(160),
ADD COLUMN IF NOT EXISTS location_details JSONB,
ADD COLUMN IF NOT EXISTS vet_clinic_name VARCHAR(160),
ADD COLUMN IF NOT EXISTS vet_clinic_address VARCHAR(500),
ADD COLUMN IF NOT EXISTS vet_account_number VARCHAR(120),
@@ -342,9 +380,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ON pending_bird_transfers (LOWER(destination_owner_email), created_at DESC)
WHERE completed_at IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_bird_transfers_open_bird
ON pending_bird_transfers (bird_id)
WHERE completed_at IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_bird_transfers_open_bird
ON pending_bird_transfers (bird_id)
WHERE completed_at IS NULL;
CREATE TABLE IF NOT EXISTS bird_transfer_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -368,9 +406,35 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
WHERE completed_at IS NULL
AND revoked_at IS NULL;
CREATE TABLE IF NOT EXISTS flock_notes (
CREATE TABLE IF NOT EXISTS bird_timeline_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
event_type VARCHAR(40) NOT NULL,
from_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL,
to_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL,
from_workspace_name VARCHAR(160),
to_workspace_name VARCHAR(160),
from_owner_email VARCHAR(255),
to_owner_email VARCHAR(255),
location_label VARCHAR(160),
location_details JSONB,
note TEXT,
event_date DATE NOT NULL DEFAULT CURRENT_DATE,
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE bird_timeline_events
ADD COLUMN IF NOT EXISTS note TEXT,
ADD COLUMN IF NOT EXISTS location_details JSONB,
ADD COLUMN IF NOT EXISTS event_date DATE NOT NULL DEFAULT CURRENT_DATE;
CREATE INDEX IF NOT EXISTS idx_bird_timeline_events_bird_created
ON bird_timeline_events (bird_id, event_date DESC, created_at DESC);
CREATE TABLE IF NOT EXISTS flock_notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
bird_id UUID REFERENCES birds(id) ON DELETE SET NULL,
title VARCHAR(160) NOT NULL,
body TEXT NOT NULL,
-54
View File
@@ -1,54 +0,0 @@
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()]);
}
@@ -40,5 +40,3 @@ export const closeAdoptionReportQueue = async () => {
await adoptionReportQueue.close();
await adoptionReportQueueEvents.close();
};
export const getAdoptionReportQueueCounts = () => adoptionReportQueue.getJobCounts('waiting', 'active', 'delayed', 'completed', 'failed');
+14 -32
View File
@@ -103,26 +103,10 @@ const fitText = (doc: PDFKit.PDFDocument, text: string, x: number, y: number, wi
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);
const drawFact = (doc: PDFKit.PDFDocument, label: string, value: string, x: number, y: number, width: number) => {
doc.roundedRect(x, y, width, 43, 6).fillAndStroke(colors.panel, colors.border);
doc.fillColor(colors.muted).fontSize(7).font('Helvetica-Bold').text(label.toUpperCase(), x + 8, y + 8, { width: width - 16 });
doc.fillColor(colors.ink).fontSize(10).font('Helvetica-Bold').text(value, x + 8, y + 21, {
width: width - 16,
height: cardHeight - 27,
lineGap: 1,
ellipsis: true,
});
return cardHeight;
doc.fillColor(colors.ink).fontSize(10).font('Helvetica-Bold').text(value, x + 8, y + 21, { width: width - 16, ellipsis: true });
};
const drawTextCard = (doc: PDFKit.PDFDocument, label: string, value: string, x: number, y: number, width: number, height = 58) => {
@@ -160,12 +144,8 @@ const drawSimpleWeightChart = (doc: PDFKit.PDFDocument, weights: WeightRow[], bi
}
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());
}
startDate.setUTCDate(startDate.getUTCDate() - 29);
const visibleWeights = plottedWeights.filter((entry) => {
const recordedOn = new Date(`${entry.recorded_on.slice(0, 10)}T00:00:00Z`);
return recordedOn >= startDate && recordedOn <= latestDate;
@@ -377,14 +357,16 @@ export const renderAdoptionReportPdf = async ({
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;
const vetFacts = [
['Clinic name', bird.vet_clinic_name || 'Not recorded'],
['Clinic address', bird.vet_clinic_address || 'Not recorded'],
['Account #', bird.vet_account_number || 'Not recorded'],
['Dr. name', bird.vet_doctor_name || 'Not recorded'],
];
vetFacts.forEach(([label, value], index) => {
drawFact(doc, label, value, page.margin + (index % 2) * (factWidth + factGap), y + Math.floor(index / 2) * 50, factWidth);
});
y += Math.ceil(vetFacts.length / 2) * 50 + 8;
y = drawSectionTitle(doc, 'Vet Visit History', y);
y = drawTable(
+1 -5
View File
@@ -1,12 +1,8 @@
import path from 'path';
import sharp from 'sharp';
import {
getBirdById,
listVetVisitsForBird,
listWeightsForBird,
} from '../repositories/birdRepository.js';
import { listFlockNotes } from '../repositories/auditRepository.js';
import { getBirdById, listVetVisitsForBird, listWeightsForBird } from '../repositories/birdRepository.js';
import { getS3ImageStorageConfig } from '../storage/imageStorageConfig.js';
import { getSignedS3ObjectUrl } from '../storage/s3Client.js';
import type { BirdRow } from '../types.js';
+55 -36
View File
@@ -6,10 +6,7 @@ import {
createBird,
createPendingBirdTransfer,
getBirdById,
getOpenBirdTransferCode,
getOpenBirdTransferCodeForBird,
listWeightsForBird,
markBirdTransferCodeCompleted,
transferBirdToWorkspace,
} from './birdRepository.js';
import { mockDb } from '../test/mockDb.js';
@@ -191,6 +188,46 @@ test('completePendingBirdTransfersForOwner moves pending birds and marks complet
],
},
{ rowCount: 1, rows: [] },
{
rowCount: 1,
rows: [
{
workspace_id: 10,
workspace_name: 'Original Flock',
owner_email: 'sender@example.com',
},
],
},
{
rowCount: 1,
rows: [
{
workspace_id: 22,
workspace_name: 'Receiving Flock',
owner_email: 'receiver@example.com',
},
],
},
{
rowCount: 1,
rows: [
{
id: 'timeline-1',
bird_id: 'bird-1',
event_type: 'transferred',
from_workspace_id: 10,
to_workspace_id: 22,
from_workspace_name: 'Original Flock',
to_workspace_name: 'Receiving Flock',
from_owner_email: 'sender@example.com',
to_owner_email: 'receiver@example.com',
location_label: 'Receiving Flock',
location_details: null,
created_by_user_id: 'user-1',
created_at: '2026-04-15T00:00:00.000Z',
},
],
},
);
const result = await completePendingBirdTransfersForOwner('receiver@example.com', 22);
@@ -200,37 +237,19 @@ test('completePendingBirdTransfersForOwner moves pending birds and marks complet
assert.deepEqual(calls[1].params, ['bird-1', 10, 22]);
assert.deepEqual(calls[2].params, ['transfer-1', 22]);
assert.match(calls[2].text, /completed_at = CURRENT_TIMESTAMP/);
});
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/);
assert.deepEqual(calls[5].params, [
'bird-1',
'transferred',
10,
22,
'Original Flock',
'Receiving Flock',
'sender@example.com',
'receiver@example.com',
null,
null,
null,
'user-1',
null,
]);
});
+177 -40
View File
@@ -5,6 +5,8 @@ import type {
BirdMilestoneReminderDeliveryRow,
BirdMilestoneReminderType,
BirdRow,
BirdTimelineEventRow,
BirdTimelineEventType,
BirdTransferCodeRow,
LostBirdMatchRow,
MedicationAdministrationRow,
@@ -26,6 +28,8 @@ const birdSelectFields = `
birds.motivators,
birds.demotivators,
birds.favorite_snack,
birds.location_label,
birds.location_details,
birds.vet_clinic_name,
birds.vet_clinic_address,
birds.vet_account_number,
@@ -51,6 +55,34 @@ const birdSelectFields = `
latest.recorded_on::text AS latest_recorded_on
`;
type WorkspaceTimelineSnapshot = {
workspace_id: number;
workspace_name: string;
owner_email: string | null;
};
const getWorkspaceTimelineSnapshot = async (workspaceId: number) => {
const result = await db.query<WorkspaceTimelineSnapshot>(
`SELECT
workspaces.id AS workspace_id,
workspaces.name AS workspace_name,
COALESCE(workspaces.billing_email, owner_member.invite_email, owner_member.email) AS owner_email
FROM workspaces
LEFT JOIN LATERAL (
SELECT invite_email, email
FROM workspace_members
WHERE workspace_members.workspace_id = workspaces.id
AND workspace_members.role = 'owner'
ORDER BY accepted_at DESC NULLS LAST, created_at ASC
LIMIT 1
) owner_member ON TRUE
WHERE workspaces.id = $1`,
[workspaceId],
);
return result.rows[0] ?? null;
};
export const getBirdById = async (birdId: string, workspaceId: number) => {
const result = await db.query<BirdRow>(
`SELECT
@@ -134,6 +166,102 @@ export const listMemorializedBirds = async (workspaceId: number) => {
return result.rows;
};
export const createBirdTimelineEvent = async ({
birdId,
eventType,
fromWorkspaceId,
toWorkspaceId,
locationLabel,
locationDetails,
note,
eventDate,
createdByUserId,
}: {
birdId: string;
eventType: BirdTimelineEventType;
fromWorkspaceId?: number | null;
toWorkspaceId?: number | null;
locationLabel?: string | null;
locationDetails?: Record<string, unknown> | null;
note?: string | null;
eventDate?: string | null;
createdByUserId?: string | null;
}) => {
const [fromWorkspace, toWorkspace] = await Promise.all([
fromWorkspaceId ? getWorkspaceTimelineSnapshot(fromWorkspaceId) : Promise.resolve(null),
toWorkspaceId ? getWorkspaceTimelineSnapshot(toWorkspaceId) : Promise.resolve(null),
]);
const result = await db.query<BirdTimelineEventRow>(
`INSERT INTO bird_timeline_events (
bird_id,
event_type,
from_workspace_id,
to_workspace_id,
from_workspace_name,
to_workspace_name,
from_owner_email,
to_owner_email,
location_label,
note,
event_date,
created_by_user_id,
location_details
)
VALUES (
$1,
$2,
$3,
$4,
$5::varchar(160),
$6::varchar(160),
$7::varchar(320),
$8::varchar(320),
COALESCE($9::varchar(160), $6::varchar(160), $5::varchar(160)),
$10,
COALESCE($11::date, CURRENT_DATE),
$12,
$13
)
RETURNING id, bird_id, event_type, from_workspace_id, to_workspace_id, from_workspace_name, to_workspace_name, from_owner_email, to_owner_email, location_label, location_details, note, event_date::text, created_by_user_id, created_at`,
[
birdId,
eventType,
fromWorkspaceId ?? null,
toWorkspaceId ?? null,
fromWorkspace?.workspace_name ?? null,
toWorkspace?.workspace_name ?? null,
fromWorkspace?.owner_email ?? null,
toWorkspace?.owner_email ?? null,
locationLabel ?? null,
note ?? null,
eventDate ?? null,
createdByUserId ?? null,
locationDetails ?? null,
],
);
return result.rows[0] ?? null;
};
export const listBirdTimelineEvents = async (birdId: string, workspaceId: number) => {
const result = await db.query<BirdTimelineEventRow>(
`SELECT id, bird_id, event_type, from_workspace_id, to_workspace_id, from_workspace_name, to_workspace_name, from_owner_email, to_owner_email, location_label, location_details, note, event_date::text, created_by_user_id, created_at
FROM bird_timeline_events
WHERE bird_id = $1
AND EXISTS (
SELECT 1
FROM birds
WHERE birds.id = bird_timeline_events.bird_id
AND birds.workspace_id = $2
)
ORDER BY event_date DESC, created_at DESC`,
[birdId, workspaceId],
);
return result.rows;
};
export const findBirdsByBandId = async (tagId: string) => {
const result = await db.query<LostBirdMatchRow>(
`SELECT
@@ -367,6 +495,8 @@ export const createBird = async ({
motivators,
demotivators,
favoriteSnack,
locationLabel = null,
locationDetails = null,
vetClinicName = null,
vetClinicAddress = null,
vetAccountNumber = null,
@@ -392,6 +522,8 @@ export const createBird = async ({
motivators: string | null;
demotivators: string | null;
favoriteSnack: string | null;
locationLabel?: string | null;
locationDetails?: Record<string, unknown> | null;
vetClinicName?: string | null;
vetClinicAddress?: string | null;
vetAccountNumber?: string | null;
@@ -410,9 +542,9 @@ export const createBird = async ({
publicProfileEnabled?: boolean;
}) => {
const result = await db.query<BirdRow>(
`INSERT INTO birds (id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth, gotcha_day, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled)
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, 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`,
`INSERT INTO birds (id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, location_details, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth, gotcha_day, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled)
VALUES (COALESCE($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26)
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, location_details, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`,
[
birdId ?? null,
workspaceId,
@@ -422,6 +554,8 @@ export const createBird = async ({
motivators,
demotivators,
favoriteSnack,
locationLabel,
locationDetails,
vetClinicName,
vetClinicAddress,
vetAccountNumber,
@@ -453,6 +587,8 @@ export const updateBird = async ({
motivators,
demotivators,
favoriteSnack,
locationLabel,
locationDetails,
vetClinicName,
vetClinicAddress,
vetAccountNumber,
@@ -478,6 +614,8 @@ export const updateBird = async ({
motivators: string | null;
demotivators: string | null;
favoriteSnack: string | null;
locationLabel: string | null;
locationDetails?: Record<string, unknown> | null;
vetClinicName: string | null;
vetClinicAddress: string | null;
vetAccountNumber: string | null;
@@ -503,26 +641,28 @@ export const updateBird = async ({
motivators = $5,
demotivators = $6,
favorite_snack = $7,
vet_clinic_name = $8,
vet_clinic_address = $9,
vet_account_number = $10,
vet_doctor_name = $11,
gender = $12,
date_of_birth = $13,
gotcha_day = $14,
chart_color = $15,
photo_data_url = $16,
photo_object_key = $17,
photo_content_type = $18,
photo_updated_at = $19,
notify_on_dob = $20,
notify_on_gotcha_day = $21,
public_profile_code = $22,
public_profile_enabled = $23
location_label = $8,
vet_clinic_name = $9,
vet_clinic_address = $10,
vet_account_number = $11,
vet_doctor_name = $12,
gender = $13,
date_of_birth = $14,
gotcha_day = $15,
chart_color = $16,
photo_data_url = $17,
photo_object_key = $18,
photo_content_type = $19,
photo_updated_at = $20,
notify_on_dob = $21,
notify_on_gotcha_day = $22,
public_profile_code = $23,
public_profile_enabled = $24,
location_details = $25
WHERE id = $1
AND workspace_id = $24
AND workspace_id = $26
AND memorialized_at IS NULL
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, location_details, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
(
SELECT weight_grams::text
FROM weight_records
@@ -545,6 +685,7 @@ export const updateBird = async ({
motivators,
demotivators,
favoriteSnack,
locationLabel,
vetClinicName,
vetClinicAddress,
vetAccountNumber,
@@ -561,6 +702,7 @@ export const updateBird = async ({
notifyOnGotchaDay,
publicProfileCode,
publicProfileEnabled,
locationDetails ?? null,
workspaceId,
],
);
@@ -590,7 +732,7 @@ export const memorializeBird = async ({
WHERE id = $1
AND workspace_id = $2
AND memorialized_at IS NULL
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, location_details, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
(
SELECT weight_grams::text
FROM weight_records
@@ -626,7 +768,7 @@ export const updateMemorialReminderPreference = async ({
WHERE id = $1
AND workspace_id = $2
AND memorialized_at IS NOT NULL
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, location_details, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
(
SELECT weight_grams::text
FROM weight_records
@@ -666,7 +808,7 @@ export const transferBirdToWorkspace = async (birdId: string, sourceWorkspaceId:
WHERE id = $1
AND workspace_id = $2
AND memorialized_at IS NULL
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, location_details, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
(
SELECT weight_grams::text
FROM weight_records
@@ -762,6 +904,17 @@ export const completePendingBirdTransfersForOwner = async (ownerEmail: string, t
}
await markPendingBirdTransferCompleted(transfer.id, targetWorkspaceId);
try {
await createBirdTimelineEvent({
birdId: bird.id,
eventType: 'transferred',
fromWorkspaceId: transfer.source_workspace_id,
toWorkspaceId: targetWorkspaceId,
createdByUserId: transfer.requested_by_user_id,
});
} catch (timelineError) {
console.error('Unable to write bird timeline event', timelineError);
}
completed += 1;
} catch (error) {
failed += 1;
@@ -809,22 +962,6 @@ export const createBirdTransferCode = async ({
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 & {
@@ -0,0 +1,153 @@
import { db } from '../db/client.js';
import type { DailyEducationQuestion, DailyEducationRow, EducationQuestionRow } from '../types.js';
export const getEducationOptOut = async (userId: string) => {
const result = await db.query<{ education_opt_out: boolean }>(
`SELECT education_opt_out
FROM users
WHERE id = $1`,
[userId],
);
return result.rows[0]?.education_opt_out ?? false;
};
export const updateEducationOptOut = async (userId: string, educationOptOut: boolean) => {
const result = await db.query<{ education_opt_out: boolean }>(
`UPDATE users
SET education_opt_out = $2
WHERE id = $1
RETURNING education_opt_out`,
[userId, educationOptOut],
);
return result.rows[0]?.education_opt_out ?? educationOptOut;
};
export const getDailyEducationForDate = async (publishDate?: string) => {
const result = publishDate
? await db.query<DailyEducationRow>(
`SELECT id, publish_date::text, fact, quiz_questions, created_by_user_id, created_at, updated_at
FROM daily_education
WHERE publish_date = $1`,
[publishDate],
)
: await db.query<DailyEducationRow>(
`SELECT id, publish_date::text, fact, quiz_questions, created_by_user_id, created_at, updated_at
FROM daily_education
WHERE publish_date = CURRENT_DATE`,
);
return result.rows[0] ?? null;
};
export const listDailyEducationForAdmin = async () => {
const result = await db.query<DailyEducationRow>(
`SELECT id, publish_date::text, fact, quiz_questions, created_by_user_id, created_at, updated_at
FROM daily_education
ORDER BY publish_date DESC
LIMIT 120`,
);
return result.rows;
};
export const upsertDailyEducation = async ({
publishDate,
fact,
createdByUserId,
}: {
publishDate: string;
fact: string;
createdByUserId: string;
}) => {
const result = await db.query<DailyEducationRow>(
`INSERT INTO daily_education (publish_date, fact, created_by_user_id)
VALUES ($1, $2, $3)
ON CONFLICT (publish_date) DO UPDATE
SET fact = EXCLUDED.fact,
updated_at = CURRENT_TIMESTAMP
RETURNING id, publish_date::text, fact, quiz_questions, created_by_user_id, created_at, updated_at`,
[publishDate, fact, createdByUserId],
);
return result.rows[0];
};
export const listEducationQuestionsForAdmin = async () => {
const result = await db.query<EducationQuestionRow>(
`SELECT id, prompt, options, correct_answer_index, explanation, created_by_user_id, created_at, updated_at
FROM education_question_bank
ORDER BY updated_at DESC, created_at DESC
LIMIT 400`,
);
return result.rows;
};
export const listDailyEducationQuestions = async (seedDate?: string) => {
const result = await db.query<EducationQuestionRow>(
`SELECT id, prompt, options, correct_answer_index, explanation, created_by_user_id, created_at, updated_at
FROM education_question_bank
ORDER BY md5(COALESCE($1::text, CURRENT_DATE::text) || id::text)
LIMIT 4`,
[seedDate ?? null],
);
return result.rows;
};
export const createEducationQuestion = async ({
question,
createdByUserId,
}: {
question: DailyEducationQuestion;
createdByUserId: string;
}) => {
const result = await db.query<EducationQuestionRow>(
`INSERT INTO education_question_bank (prompt, options, correct_answer_index, explanation, created_by_user_id)
VALUES ($1, $2::jsonb, $3, $4, $5)
RETURNING id, prompt, options, correct_answer_index, explanation, created_by_user_id, created_at, updated_at`,
[question.prompt, JSON.stringify(question.options), question.correctAnswerIndex, question.explanation, createdByUserId],
);
return result.rows[0];
};
export const updateEducationQuestion = async (questionId: string, question: DailyEducationQuestion) => {
const result = await db.query<EducationQuestionRow>(
`UPDATE education_question_bank
SET prompt = $2,
options = $3::jsonb,
correct_answer_index = $4,
explanation = $5,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING id, prompt, options, correct_answer_index, explanation, created_by_user_id, created_at, updated_at`,
[questionId, question.prompt, JSON.stringify(question.options), question.correctAnswerIndex, question.explanation],
);
return result.rows[0] ?? null;
};
export const deleteEducationQuestion = async (questionId: string) => {
const result = await db.query<{ id: string }>(
`DELETE FROM education_question_bank
WHERE id = $1
RETURNING id`,
[questionId],
);
return Boolean(result.rowCount);
};
export const deleteDailyEducation = async (educationId: string) => {
const result = await db.query<{ id: string }>(
`DELETE FROM daily_education
WHERE id = $1
RETURNING id`,
[educationId],
);
return Boolean(result.rowCount);
};
+51
View File
@@ -13,9 +13,38 @@ export type UserRow = {
email: string;
password_hash: string | null;
name: string;
education_opt_out?: boolean;
created_at: string;
};
export type DailyEducationQuestion = {
prompt: string;
options: string[];
correctAnswerIndex: number;
explanation: string | null;
};
export type DailyEducationRow = {
id: string;
publish_date: string;
fact: string;
quiz_questions: DailyEducationQuestion[];
created_by_user_id: string | null;
created_at: string;
updated_at: string;
};
export type EducationQuestionRow = {
id: string;
prompt: string;
options: string[];
correct_answer_index: number;
explanation: string | null;
created_by_user_id: string | null;
created_at: string;
updated_at: string;
};
export type WorkspaceRow = {
id: number;
name: string;
@@ -101,6 +130,8 @@ export type BirdRow = {
motivators: string | null;
demotivators: string | null;
favorite_snack: string | null;
location_label: string | null;
location_details: Record<string, unknown> | null;
vet_clinic_name: string | null;
vet_clinic_address: string | null;
vet_account_number: string | null;
@@ -174,6 +205,26 @@ export type BirdTransferCodeRow = {
created_at: string;
};
export type BirdTimelineEventType = 'profile_created' | 'transferred' | 'location_updated' | 'owner_changed' | 'manual_note';
export type BirdTimelineEventRow = {
id: string;
bird_id: string;
event_type: BirdTimelineEventType;
from_workspace_id: number | null;
to_workspace_id: number | null;
from_workspace_name: string | null;
to_workspace_name: string | null;
from_owner_email: string | null;
to_owner_email: string | null;
location_label: string | null;
location_details: Record<string, unknown> | null;
note: string | null;
event_date: string;
created_by_user_id: string | null;
created_at: string;
};
export type WeightRow = {
id: string;
bird_id: string;
+2 -18
View File
@@ -55,6 +55,7 @@ services:
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production}
BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production}
MAPBOX_ACCESS_TOKEN: ${MAPBOX_ACCESS_TOKEN:-}
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
@@ -96,12 +97,6 @@ services:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "node", "dist/healthcheck.js", "api-ready"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
labels:
- traefik.enable=true
- traefik.docker.network=traefik
@@ -142,6 +137,7 @@ services:
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production}
BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production}
MAPBOX_ACCESS_TOKEN: ${MAPBOX_ACCESS_TOKEN:-}
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
@@ -160,12 +156,6 @@ services:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "node", "dist/healthcheck.js", "worker"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
restart: unless-stopped
frontend:
@@ -177,12 +167,6 @@ services:
container_name: flockpal-frontend
depends_on:
- 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:
- traefik.enable=true
- traefik.docker.network=traefik
+3 -1
View File
@@ -53,6 +53,7 @@ services:
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
BACKEND_URL: ${BACKEND_URL:-http://localhost:5000}
MAPBOX_ACCESS_TOKEN: ${MAPBOX_ACCESS_TOKEN:-}
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
@@ -129,6 +130,7 @@ services:
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
BACKEND_URL: ${BACKEND_URL:-http://localhost:5000}
MAPBOX_ACCESS_TOKEN: ${MAPBOX_ACCESS_TOKEN:-}
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
@@ -160,7 +162,7 @@ services:
dockerfile: Dockerfile.dev
container_name: flockpal-frontend
environment:
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-http://localhost:5000/api}
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-/api}
depends_on:
- backend
ports:
+2 -35
View File
@@ -319,47 +319,14 @@ Validation failures return `400` with this shape:
#### `GET /api/health`
Public readiness-compatible health check. Verifies backend dependencies.
Public health check.
Response `200`:
```json
{
"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 }
}
}
{ "ok": true }
```
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
#### `GET /api/metrics`
+1
View File
@@ -7,6 +7,7 @@ RUN npm ci
COPY tsconfig*.json ./
COPY vite.config.ts ./
COPY index.html ./
COPY public ./public
COPY src ./src
RUN npm run build
+1
View File
@@ -5,6 +5,7 @@ RUN npm install
COPY tsconfig*.json ./
COPY vite.config.ts ./
COPY index.html ./
COPY public ./public
COPY src ./src
EXPOSE 3000
CMD ["npm", "run", "dev", "--", "--host"]
-6
View File
@@ -12,12 +12,6 @@ server {
add_header X-Frame-Options "SAMEORIGIN" 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 / {
try_files $uri $uri/ /index.html;
}
+1519 -687
View File
File diff suppressed because it is too large Load Diff
+452 -11
View File
@@ -1,3 +1,4 @@
:root {
--ink: #1f2a2a;
--muted: #5d5f59;
@@ -616,14 +617,6 @@ textarea {
break-inside: avoid;
}
.settings-card-bird-profiles {
order: 1;
}
.settings-card-bird-profiles[hidden] {
display: none;
}
.settings-card-collaborators {
order: 2;
}
@@ -750,6 +743,123 @@ textarea {
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 {
display: flex;
gap: 0.75rem;
@@ -1215,6 +1325,264 @@ textarea {
align-items: center;
}
.bird-timeline-card {
grid-template-columns: 18px minmax(0, 1fr);
gap: 0.75rem;
border: 1px solid rgba(39, 105, 179, 0.12);
border-radius: 8px;
background: rgba(255, 255, 255, 0.76);
}
.bird-timeline-graph-card {
padding: 0.85rem;
border: 1px solid rgba(39, 105, 179, 0.12);
border-radius: 8px;
background: rgba(255, 255, 255, 0.72);
}
.bird-timeline-graph {
position: relative;
min-height: 340px;
isolation: isolate;
}
.bird-timeline-graph-line {
position: absolute;
left: calc(8.125% + 18px);
right: 8.125%;
top: 50%;
height: 4px;
border-radius: 999px;
background: var(--timeline-color, var(--accent-green));
transform: translateY(-50%);
}
.bird-timeline-graph-scale {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
}
.bird-timeline-graph-tick {
position: absolute;
top: calc(50% + 78px);
transform: translateX(-50%);
color: var(--muted);
font-size: 0.68rem;
font-weight: 600;
line-height: 1;
white-space: nowrap;
}
.bird-timeline-graph-tick::before {
content: "";
position: absolute;
left: 50%;
bottom: calc(100% + 0.35rem);
width: 1px;
height: 68px;
background: linear-gradient(to bottom, rgba(39, 105, 179, 0.22), rgba(39, 105, 179, 0));
transform: translateX(-50%);
}
.bird-timeline-graph-tick.today {
color: var(--accent-green);
font-weight: 800;
}
.bird-timeline-graph-tick.today::before {
background: linear-gradient(to bottom, rgba(35, 138, 90, 0.42), rgba(35, 138, 90, 0));
}
.bird-timeline-graph-point {
position: absolute;
top: 50%;
width: 136px;
height: 0;
transform: translateX(-50%);
z-index: 1;
}
.bird-timeline-graph-dot {
position: absolute;
left: 50%;
z-index: 2;
display: grid;
place-items: center;
width: 34px;
height: 34px;
border: 0;
border-radius: 0;
background: transparent;
}
.bird-timeline-graph-point.above .bird-timeline-graph-dot {
left: calc(50% + var(--branch-offset, 0px));
bottom: var(--branch-distance, 34px);
transform: translate(-50%, 50%);
}
.bird-timeline-graph-point.below .bird-timeline-graph-dot {
left: calc(50% + var(--branch-offset, 0px));
top: var(--branch-distance, 34px);
transform: translate(-50%, -50%);
}
.bird-timeline-graph-point.on-line .bird-timeline-graph-dot {
top: 0;
transform: translate(-50%, -50%);
}
.bird-timeline-graph-point.hatch_date .bird-timeline-graph-dot {
width: 34px;
height: 34px;
border: 0;
border-radius: 50%;
background: #fffdf9;
}
.bird-timeline-graph-connector {
position: absolute;
left: 50%;
width: 2px;
height: var(--branch-connector-length, var(--branch-distance, 34px));
background: repeating-linear-gradient(
to bottom,
rgba(39, 105, 179, 0.18) 0 4px,
transparent 4px 9px
);
transform: translateX(-50%) rotate(var(--branch-angle, 0deg));
}
.bird-timeline-graph-point.above .bird-timeline-graph-connector {
bottom: 0;
transform-origin: bottom center;
}
.bird-timeline-graph-point.below .bird-timeline-graph-connector {
top: 0;
transform-origin: top center;
}
.bird-timeline-graph-point.on-line .bird-timeline-graph-connector {
display: none;
}
.bird-timeline-graph-icon {
width: 24px;
height: 24px;
color: var(--accent-green);
fill: currentColor;
}
.bird-timeline-graph-point.hatch_date .bird-timeline-graph-icon {
width: 34px;
height: 34px;
color: var(--accent-gold);
font-size: 34px;
}
.bird-timeline-graph-point.owner_changed .bird-timeline-graph-icon {
color: var(--accent-blue);
}
.bird-timeline-graph-point.transferred .bird-timeline-graph-icon {
color: var(--accent-red);
}
.bird-timeline-graph-copy {
position: absolute;
left: 50%;
width: 124px;
transform: translateX(-50%);
display: grid;
gap: 0.08rem;
justify-items: center;
text-align: center;
color: var(--ink);
font-size: 0.68rem;
}
.bird-timeline-graph-copy strong {
font-weight: 700;
line-height: 1.15;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
overflow-wrap: anywhere;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.bird-timeline-graph-copy span {
color: var(--muted);
line-height: 1.1;
}
.bird-timeline-graph-point.above .bird-timeline-graph-copy {
left: calc(50% + var(--branch-offset, 0px));
bottom: calc(var(--branch-distance, 34px) + 24px);
}
.bird-timeline-graph-point.below .bird-timeline-graph-copy {
left: calc(50% + var(--branch-offset, 0px));
top: calc(var(--branch-distance, 34px) + 24px);
}
.bird-timeline-graph-point.on-line .bird-timeline-graph-copy {
bottom: 28px;
}
.bird-timeline-form {
padding: 0.85rem;
border: 1px solid rgba(35, 138, 90, 0.14);
border-radius: 8px;
background: rgba(240, 248, 244, 0.54);
}
.bird-timeline-marker {
width: 12px;
height: 12px;
margin-top: 0.25rem;
border-radius: 999px;
background: var(--accent-green);
box-shadow: 0 0 0 4px rgba(35, 138, 90, 0.12);
}
.bird-timeline-content {
display: grid;
gap: 0.3rem;
min-width: 0;
}
.bird-timeline-content > div {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
}
.bird-timeline-content strong,
.bird-timeline-content span,
.bird-timeline-content small,
.bird-timeline-content p {
overflow-wrap: anywhere;
}
.bird-timeline-content span,
.bird-timeline-content small {
color: var(--muted);
}
.bird-timeline-content p {
margin: 0;
color: var(--ink);
}
.legend-grid,
.detail-grid,
.summary-grid {
@@ -1314,6 +1682,7 @@ textarea {
.bird-detail-tab .info-tab-icon,
.bird-detail-tab .note-tab-icon,
.bird-detail-tab .report-tab-icon,
.bird-detail-tab .timeline-tab-icon,
.bird-detail-tab .audit-tab-icon,
.bird-detail-tab .vet-tab-icon {
width: 24px;
@@ -1342,7 +1711,7 @@ textarea {
.profile-copy {
display: grid;
gap: 0.3rem;
gap: 0.18rem;
}
.profile-copy h3 {
@@ -1350,6 +1719,10 @@ textarea {
font-size: 1.6rem;
}
.profile-copy p {
margin: 0;
}
.profile-title {
display: inline-flex;
align-items: center;
@@ -1798,6 +2171,60 @@ label {
font-size: 0.95rem;
}
.verified-location-field {
display: grid;
gap: 0.75rem;
}
.verified-location-label {
display: grid;
gap: 0.35rem;
font-weight: 600;
}
.verified-location-search-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.65rem;
align-items: end;
}
.verified-location-search-row.has-selected-location {
grid-template-columns: minmax(0, 1fr) auto auto;
}
.verified-location-search-row.has-selected-location input {
border-color: rgba(35, 138, 90, 0.45);
background: rgba(35, 138, 90, 0.08);
box-shadow: 0 0 0 3px rgba(35, 138, 90, 0.08);
}
.verified-location-result small {
color: var(--muted);
}
.verified-location-results {
display: grid;
gap: 0.45rem;
}
.verified-location-result {
display: grid;
gap: 0.15rem;
width: 100%;
padding: 0.8rem 0.9rem;
text-align: left;
color: var(--ink);
border: 1px solid rgba(53, 129, 98, 0.2);
border-radius: 8px;
background: rgba(255, 255, 255, 0.55);
}
.verified-location-result:hover {
border-color: rgba(39, 105, 179, 0.28);
background: rgba(255, 255, 255, 0.78);
}
.toggle-card input[type="checkbox"] {
width: 20px;
height: 20px;
@@ -2117,6 +2544,11 @@ label {
}
@media (max-width: 980px) {
.education-admin-basics,
.quiz-editor-options {
grid-template-columns: 1fr;
}
.app-shell,
.auth-panel,
.hero-card,
@@ -2127,7 +2559,8 @@ label {
.inline-form,
.profile-hero,
.photo-editor,
.settings-nested-grid {
.settings-nested-grid,
.verified-location-search-row {
grid-template-columns: 1fr;
}
@@ -2204,17 +2637,25 @@ label {
}
.page-tabs {
grid-template-columns: repeat(auto-fit, minmax(64px, 1fr));
grid-auto-flow: column;
grid-auto-columns: minmax(5.5rem, max-content);
grid-template-columns: none;
gap: 0.4rem;
min-width: 0;
overflow-x: auto;
padding-bottom: 0.1rem;
scrollbar-width: thin;
}
.page-tab {
min-height: 42px;
min-width: 5.5rem;
padding: 0.55rem 0.65rem;
border-radius: 14px;
text-align: center;
font-size: 0.92rem;
font-weight: 700;
white-space: nowrap;
}
.side-nav .secondary-button {
+6
View File
@@ -5,5 +5,11 @@ export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://backend:5000',
changeOrigin: true,
},
},
},
});