41 Commits

Author SHA1 Message Date
blaisadmin 4a43a450f3 Additional Genders
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 2m43s
2026-06-17 22:04:42 -04:00
Corey Blais 46605d8717 Limit editable weight entries to three
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 1m30s
2026-06-17 10:08:24 -04:00
Corey Blais 8f1144de1a Allow four editable weight entries
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 3m2s
2026-06-17 09:57:23 -04:00
Corey Blais 53b75588a2 weight edit fixes
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 2m36s
2026-06-16 18:13:54 -04:00
Corey Blais 1849ecd73b Updated weight edit 2026-06-16 18:13:54 -04:00
Corey Blais 53b7d34520 trimmed weight edit 2026-06-16 18:13:54 -04:00
Corey Blais f65a4bed24 adding weight edits 2026-06-16 18:13:54 -04:00
blaisadmin cc4a2382c6 Add production health checks
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 1m46s
2026-06-05 22:28:48 -04:00
blaisadmin 5735bb7735 Adding promoting to owner
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 2m23s
2026-06-05 21:45:58 -04:00
blaisadmin 88ff06237e Adjusting role actions 2026-06-05 21:45:58 -04:00
Corey Blais fbb13561b0 Medication reminder and pdr worker 2026-06-05 21:45:58 -04:00
Corey Blais b15861c856 Use fixed screen size for adoption report sheet 2026-06-05 21:42:20 -04:00
Corey Blais 2aeaa119f7 Wrap adoption report in letter sheet 2026-06-05 21:42:20 -04:00
Corey Blais 36690c0174 Tighten adoption report page scale 2026-06-05 21:42:20 -04:00
Corey Blais b76ad35c07 Size adoption report for letter paper 2026-06-05 21:42:20 -04:00
Corey Blais 6918b55a58 Stabilize adoption report layout 2026-06-05 21:42:20 -04:00
blaisadmin 49f1713e26 Add flock member notes and audit tabs 2026-06-05 21:42:20 -04:00
Corey Blais c9fa7e4246 Cover consumed adoption transfer codes
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 1m45s
2026-06-03 14:12:10 -04:00
Corey Blais 0411ec5175 Keep adoption transfer codes stable
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 2m17s
2026-06-03 12:10:27 -04:00
Corey Blais 7b7171c109 Prevent report card text overlap
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 1m47s
2026-06-03 11:54:44 -04:00
Corey Blais c02bb4d6d8 Convert report photos for PDF embedding
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 2m37s
2026-06-03 11:20:52 -04:00
Corey Blais 603b4eee4d Render adoption reports in worker
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 1m43s
2026-06-03 10:55:09 -04:00
Corey Blais 52008f5b43 Fix adoption report photos and section order
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 2m23s
2026-06-03 10:05:48 -04:00
Corey Blais 5b57cdd6bf Fix adoption report QR and chart layout 2026-06-03 10:05:48 -04:00
Corey Blais 60eadf0847 Refine adoption report header layout 2026-06-03 10:05:48 -04:00
Corey Blais 682ccfd41f Generate adoption reports as PDFs 2026-06-03 10:05:48 -04:00
Corey Blais 59c6b19ad6 fixing analytics icon pt2
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 2m15s
2026-06-02 12:55:35 -04:00
Corey Blais aa1a4cf6ff fixing analytics icon 2026-06-02 12:55:35 -04:00
Corey Blais 5f0fad3cbb Updated adoption report 2026-06-02 12:55:35 -04:00
Corey Blais 545fae59b2 Added adoption report and transfer code 2026-06-02 12:55:35 -04:00
Corey Blais d748d2db21 Added vet info
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 2m14s
2026-06-01 15:20:47 -04:00
blaisadmin 095c91e56d Merge branch 'release/flock-member-notes-audit'
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 2m16s
2026-05-30 22:50:54 -04:00
blaisadmin f2017068d5 Auto deploy production on main merge 2026-05-30 22:47:11 -04:00
blaisadmin c9702495a3 Add flock member notes and audit tabs 2026-05-30 22:46:31 -04:00
blaisadmin e965cb55ef Revert education feature from main 2026-05-30 15:50:23 -04:00
blaisadmin 505a9b8496 Added backend limits 2026-05-30 15:43:38 -04:00
blaisadmin c6dc5b22b8 Merge dev into main 2026-05-30 15:29:48 -04:00
blaisadmin f16e88e2f0 Validate builds before deploy 2026-05-24 21:48:42 -04:00
blaisadmin 016bc187d4 Run deploy compose from host repo paths 2026-05-24 11:33:58 -04:00
blaisadmin 104f01f75d Use local mounts for production deploy workflow 2026-05-23 18:38:49 -04:00
blaisadmin 568aee3e70 Adding gitea action 2026-05-23 16:32:59 -04:00
21 changed files with 1149 additions and 3218 deletions
-1
View File
@@ -14,7 +14,6 @@ PHOTO_DELIVERY_MODE=proxy
FRONTEND_URL=http://localhost:3000 FRONTEND_URL=http://localhost:3000
BACKEND_URL=http://localhost:5000 BACKEND_URL=http://localhost:5000
VITE_API_BASE_URL=http://localhost:5000/api VITE_API_BASE_URL=http://localhost:5000/api
MAPBOX_ACCESS_TOKEN=
NODE_ENV=development NODE_ENV=development
TRUST_PROXY= TRUST_PROXY=
ADMIN_EMAILS=corey@blaishome.online ADMIN_EMAILS=corey@blaishome.online
+3 -2
View File
@@ -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:
+17 -1
View File
@@ -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:
@@ -86,7 +103,6 @@ curl -H "Authorization: Bearer <admin-token>" https://your-host/api/metrics
- `FRONTEND_URL` - `FRONTEND_URL`
- `BACKEND_URL` - `BACKEND_URL`
- `VITE_API_BASE_URL` - `VITE_API_BASE_URL`
- `MAPBOX_ACCESS_TOKEN`
- `REDIS_URL` - `REDIS_URL`
- `IMAGE_STORAGE_PROVIDER` - `IMAGE_STORAGE_PROVIDER`
- `S3_ENDPOINT` - `S3_ENDPOINT`
+190 -667
View File
File diff suppressed because it is too large Load Diff
-64
View File
@@ -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,8 +215,6 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
motivators VARCHAR(1000), motivators VARCHAR(1000),
demotivators VARCHAR(1000), demotivators VARCHAR(1000),
favorite_snack VARCHAR(160), favorite_snack VARCHAR(160),
location_label VARCHAR(160),
location_details JSONB,
vet_clinic_name VARCHAR(160), vet_clinic_name VARCHAR(160),
vet_clinic_address VARCHAR(500), vet_clinic_address VARCHAR(500),
vet_account_number VARCHAR(120), vet_account_number VARCHAR(120),
@@ -279,8 +243,6 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ADD COLUMN IF NOT EXISTS motivators VARCHAR(1000), ADD COLUMN IF NOT EXISTS motivators VARCHAR(1000),
ADD COLUMN IF NOT EXISTS demotivators VARCHAR(1000), ADD COLUMN IF NOT EXISTS demotivators VARCHAR(1000),
ADD COLUMN IF NOT EXISTS favorite_snack VARCHAR(160), ADD COLUMN IF NOT EXISTS favorite_snack VARCHAR(160),
ADD COLUMN IF NOT EXISTS location_label VARCHAR(160),
ADD COLUMN IF NOT EXISTS location_details JSONB,
ADD COLUMN IF NOT EXISTS vet_clinic_name VARCHAR(160), ADD COLUMN IF NOT EXISTS vet_clinic_name VARCHAR(160),
ADD COLUMN IF NOT EXISTS vet_clinic_address VARCHAR(500), ADD COLUMN IF NOT EXISTS vet_clinic_address VARCHAR(500),
ADD COLUMN IF NOT EXISTS vet_account_number VARCHAR(120), ADD COLUMN IF NOT EXISTS vet_account_number VARCHAR(120),
@@ -406,32 +368,6 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
WHERE completed_at IS NULL WHERE completed_at IS NULL
AND revoked_at IS NULL; AND revoked_at IS NULL;
CREATE TABLE IF NOT EXISTS bird_timeline_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
event_type VARCHAR(40) NOT NULL,
from_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL,
to_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL,
from_workspace_name VARCHAR(160),
to_workspace_name VARCHAR(160),
from_owner_email VARCHAR(255),
to_owner_email VARCHAR(255),
location_label VARCHAR(160),
location_details JSONB,
note TEXT,
event_date DATE NOT NULL DEFAULT CURRENT_DATE,
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE bird_timeline_events
ADD COLUMN IF NOT EXISTS note TEXT,
ADD COLUMN IF NOT EXISTS location_details JSONB,
ADD COLUMN IF NOT EXISTS event_date DATE NOT NULL DEFAULT CURRENT_DATE;
CREATE INDEX IF NOT EXISTS idx_bird_timeline_events_bird_created
ON bird_timeline_events (bird_id, event_date DESC, created_at DESC);
CREATE TABLE IF NOT EXISTS flock_notes ( CREATE TABLE IF NOT EXISTS flock_notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
+54
View File
@@ -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()]);
}
@@ -40,3 +40,5 @@ export const closeAdoptionReportQueue = async () => {
await adoptionReportQueue.close(); await adoptionReportQueue.close();
await adoptionReportQueueEvents.close(); await adoptionReportQueueEvents.close();
}; };
export const getAdoptionReportQueueCounts = () => adoptionReportQueue.getJobCounts('waiting', 'active', 'delayed', 'completed', 'failed');
+32 -14
View File
@@ -103,10 +103,26 @@ const fitText = (doc: PDFKit.PDFDocument, text: string, x: number, y: number, wi
return doc.y; return doc.y;
}; };
const drawFact = (doc: PDFKit.PDFDocument, label: string, value: string, x: number, y: number, width: number) => { const measureFactHeight = (doc: PDFKit.PDFDocument, value: string, width: number, minHeight = 43) => {
doc.roundedRect(x, y, width, 43, 6).fillAndStroke(colors.panel, colors.border); 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.muted).fontSize(7).font('Helvetica-Bold').text(label.toUpperCase(), x + 8, y + 8, { width: width - 16 });
doc.fillColor(colors.ink).fontSize(10).font('Helvetica-Bold').text(value, x + 8, y + 21, { width: width - 16, ellipsis: true }); 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) => { const drawTextCard = (doc: PDFKit.PDFDocument, label: string, value: string, x: number, y: number, width: number, height = 58) => {
@@ -144,8 +160,12 @@ 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 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); const startDate = new Date(latestDate);
startDate.setUTCDate(startDate.getUTCDate() - 29); startDate.setUTCDate(startDate.getUTCDate() - 13);
if (earliestDate > startDate) {
startDate.setTime(earliestDate.getTime());
}
const visibleWeights = plottedWeights.filter((entry) => { const visibleWeights = plottedWeights.filter((entry) => {
const recordedOn = new Date(`${entry.recorded_on.slice(0, 10)}T00:00:00Z`); const recordedOn = new Date(`${entry.recorded_on.slice(0, 10)}T00:00:00Z`);
return recordedOn >= startDate && recordedOn <= latestDate; return recordedOn >= startDate && recordedOn <= latestDate;
@@ -357,16 +377,14 @@ export const renderAdoptionReportPdf = async ({
y = page.margin; y = page.margin;
} }
y = drawSectionTitle(doc, 'Veterinary Clinic Info', y); y = drawSectionTitle(doc, 'Veterinary Clinic Info', y);
const vetFacts = [ drawFact(doc, 'Clinic name', bird.vet_clinic_name || 'Not recorded', page.margin, y, factWidth);
['Clinic name', bird.vet_clinic_name || 'Not recorded'], drawFact(doc, 'Account #', bird.vet_account_number || 'Not recorded', page.margin + factWidth + factGap, y, factWidth);
['Clinic address', bird.vet_clinic_address || 'Not recorded'], y += 50;
['Account #', bird.vet_account_number || 'Not recorded'], const clinicAddressHeight = measureFactHeight(doc, bird.vet_clinic_address || 'Not recorded', contentWidth, 58);
['Dr. name', bird.vet_doctor_name || 'Not recorded'], drawFact(doc, 'Clinic address', bird.vet_clinic_address || 'Not recorded', page.margin, y, contentWidth, clinicAddressHeight);
]; y += clinicAddressHeight + 7;
vetFacts.forEach(([label, value], index) => { drawFact(doc, 'Dr. name', bird.vet_doctor_name || 'Not recorded', page.margin, y, factWidth);
drawFact(doc, label, value, page.margin + (index % 2) * (factWidth + factGap), y + Math.floor(index / 2) * 50, factWidth); y += 50;
});
y += Math.ceil(vetFacts.length / 2) * 50 + 8;
y = drawSectionTitle(doc, 'Vet Visit History', y); y = drawSectionTitle(doc, 'Vet Visit History', y);
y = drawTable( y = drawTable(
+5 -1
View File
@@ -1,8 +1,12 @@
import path from 'path'; import path from 'path';
import sharp from 'sharp'; import sharp from 'sharp';
import {
getBirdById,
listVetVisitsForBird,
listWeightsForBird,
} from '../repositories/birdRepository.js';
import { listFlockNotes } from '../repositories/auditRepository.js'; import { listFlockNotes } from '../repositories/auditRepository.js';
import { getBirdById, listVetVisitsForBird, listWeightsForBird } from '../repositories/birdRepository.js';
import { getS3ImageStorageConfig } from '../storage/imageStorageConfig.js'; import { getS3ImageStorageConfig } from '../storage/imageStorageConfig.js';
import { getSignedS3ObjectUrl } from '../storage/s3Client.js'; import { getSignedS3ObjectUrl } from '../storage/s3Client.js';
import type { BirdRow } from '../types.js'; import type { BirdRow } from '../types.js';
+36 -55
View File
@@ -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';
@@ -188,46 +191,6 @@ test('completePendingBirdTransfersForOwner moves pending birds and marks complet
], ],
}, },
{ rowCount: 1, rows: [] }, { rowCount: 1, rows: [] },
{
rowCount: 1,
rows: [
{
workspace_id: 10,
workspace_name: 'Original Flock',
owner_email: 'sender@example.com',
},
],
},
{
rowCount: 1,
rows: [
{
workspace_id: 22,
workspace_name: 'Receiving Flock',
owner_email: 'receiver@example.com',
},
],
},
{
rowCount: 1,
rows: [
{
id: 'timeline-1',
bird_id: 'bird-1',
event_type: 'transferred',
from_workspace_id: 10,
to_workspace_id: 22,
from_workspace_name: 'Original Flock',
to_workspace_name: 'Receiving Flock',
from_owner_email: 'sender@example.com',
to_owner_email: 'receiver@example.com',
location_label: 'Receiving Flock',
location_details: null,
created_by_user_id: 'user-1',
created_at: '2026-04-15T00:00:00.000Z',
},
],
},
); );
const result = await completePendingBirdTransfersForOwner('receiver@example.com', 22); const result = await completePendingBirdTransfersForOwner('receiver@example.com', 22);
@@ -237,19 +200,37 @@ test('completePendingBirdTransfersForOwner moves pending birds and marks complet
assert.deepEqual(calls[1].params, ['bird-1', 10, 22]); assert.deepEqual(calls[1].params, ['bird-1', 10, 22]);
assert.deepEqual(calls[2].params, ['transfer-1', 22]); assert.deepEqual(calls[2].params, ['transfer-1', 22]);
assert.match(calls[2].text, /completed_at = CURRENT_TIMESTAMP/); assert.match(calls[2].text, /completed_at = CURRENT_TIMESTAMP/);
assert.deepEqual(calls[5].params, [ });
'bird-1',
'transferred', test('getOpenBirdTransferCode only returns unconsumed codes', async () => {
10, const { calls } = mockDb({ rowCount: 0, rows: [] });
22,
'Original Flock', const transferCode = await getOpenBirdTransferCode('ADOPT-123');
'Receiving Flock',
'sender@example.com', assert.equal(transferCode, null);
'receiver@example.com', assert.deepEqual(calls[0].params, ['ADOPT-123']);
null, assert.match(calls[0].text, /bird_transfer_codes\.completed_at IS NULL/);
null, assert.match(calls[0].text, /bird_transfer_codes\.revoked_at IS NULL/);
null, assert.match(calls[0].text, /birds\.workspace_id = bird_transfer_codes\.source_workspace_id/);
'user-1', });
null,
]); 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/);
}); });
+40 -177
View File
@@ -5,8 +5,6 @@ import type {
BirdMilestoneReminderDeliveryRow, BirdMilestoneReminderDeliveryRow,
BirdMilestoneReminderType, BirdMilestoneReminderType,
BirdRow, BirdRow,
BirdTimelineEventRow,
BirdTimelineEventType,
BirdTransferCodeRow, BirdTransferCodeRow,
LostBirdMatchRow, LostBirdMatchRow,
MedicationAdministrationRow, MedicationAdministrationRow,
@@ -28,8 +26,6 @@ const birdSelectFields = `
birds.motivators, birds.motivators,
birds.demotivators, birds.demotivators,
birds.favorite_snack, birds.favorite_snack,
birds.location_label,
birds.location_details,
birds.vet_clinic_name, birds.vet_clinic_name,
birds.vet_clinic_address, birds.vet_clinic_address,
birds.vet_account_number, birds.vet_account_number,
@@ -55,34 +51,6 @@ const birdSelectFields = `
latest.recorded_on::text AS latest_recorded_on latest.recorded_on::text AS latest_recorded_on
`; `;
type WorkspaceTimelineSnapshot = {
workspace_id: number;
workspace_name: string;
owner_email: string | null;
};
const getWorkspaceTimelineSnapshot = async (workspaceId: number) => {
const result = await db.query<WorkspaceTimelineSnapshot>(
`SELECT
workspaces.id AS workspace_id,
workspaces.name AS workspace_name,
COALESCE(workspaces.billing_email, owner_member.invite_email, owner_member.email) AS owner_email
FROM workspaces
LEFT JOIN LATERAL (
SELECT invite_email, email
FROM workspace_members
WHERE workspace_members.workspace_id = workspaces.id
AND workspace_members.role = 'owner'
ORDER BY accepted_at DESC NULLS LAST, created_at ASC
LIMIT 1
) owner_member ON TRUE
WHERE workspaces.id = $1`,
[workspaceId],
);
return result.rows[0] ?? null;
};
export const getBirdById = async (birdId: string, workspaceId: number) => { export const getBirdById = async (birdId: string, workspaceId: number) => {
const result = await db.query<BirdRow>( const result = await db.query<BirdRow>(
`SELECT `SELECT
@@ -166,102 +134,6 @@ export const listMemorializedBirds = async (workspaceId: number) => {
return result.rows; return result.rows;
}; };
export const createBirdTimelineEvent = async ({
birdId,
eventType,
fromWorkspaceId,
toWorkspaceId,
locationLabel,
locationDetails,
note,
eventDate,
createdByUserId,
}: {
birdId: string;
eventType: BirdTimelineEventType;
fromWorkspaceId?: number | null;
toWorkspaceId?: number | null;
locationLabel?: string | null;
locationDetails?: Record<string, unknown> | null;
note?: string | null;
eventDate?: string | null;
createdByUserId?: string | null;
}) => {
const [fromWorkspace, toWorkspace] = await Promise.all([
fromWorkspaceId ? getWorkspaceTimelineSnapshot(fromWorkspaceId) : Promise.resolve(null),
toWorkspaceId ? getWorkspaceTimelineSnapshot(toWorkspaceId) : Promise.resolve(null),
]);
const result = await db.query<BirdTimelineEventRow>(
`INSERT INTO bird_timeline_events (
bird_id,
event_type,
from_workspace_id,
to_workspace_id,
from_workspace_name,
to_workspace_name,
from_owner_email,
to_owner_email,
location_label,
note,
event_date,
created_by_user_id,
location_details
)
VALUES (
$1,
$2,
$3,
$4,
$5::varchar(160),
$6::varchar(160),
$7::varchar(320),
$8::varchar(320),
COALESCE($9::varchar(160), $6::varchar(160), $5::varchar(160)),
$10,
COALESCE($11::date, CURRENT_DATE),
$12,
$13
)
RETURNING id, bird_id, event_type, from_workspace_id, to_workspace_id, from_workspace_name, to_workspace_name, from_owner_email, to_owner_email, location_label, location_details, note, event_date::text, created_by_user_id, created_at`,
[
birdId,
eventType,
fromWorkspaceId ?? null,
toWorkspaceId ?? null,
fromWorkspace?.workspace_name ?? null,
toWorkspace?.workspace_name ?? null,
fromWorkspace?.owner_email ?? null,
toWorkspace?.owner_email ?? null,
locationLabel ?? null,
note ?? null,
eventDate ?? null,
createdByUserId ?? null,
locationDetails ?? null,
],
);
return result.rows[0] ?? null;
};
export const listBirdTimelineEvents = async (birdId: string, workspaceId: number) => {
const result = await db.query<BirdTimelineEventRow>(
`SELECT id, bird_id, event_type, from_workspace_id, to_workspace_id, from_workspace_name, to_workspace_name, from_owner_email, to_owner_email, location_label, location_details, note, event_date::text, created_by_user_id, created_at
FROM bird_timeline_events
WHERE bird_id = $1
AND EXISTS (
SELECT 1
FROM birds
WHERE birds.id = bird_timeline_events.bird_id
AND birds.workspace_id = $2
)
ORDER BY event_date DESC, created_at DESC`,
[birdId, workspaceId],
);
return result.rows;
};
export const findBirdsByBandId = async (tagId: string) => { export const findBirdsByBandId = async (tagId: string) => {
const result = await db.query<LostBirdMatchRow>( const result = await db.query<LostBirdMatchRow>(
`SELECT `SELECT
@@ -495,8 +367,6 @@ export const createBird = async ({
motivators, motivators,
demotivators, demotivators,
favoriteSnack, favoriteSnack,
locationLabel = null,
locationDetails = null,
vetClinicName = null, vetClinicName = null,
vetClinicAddress = null, vetClinicAddress = null,
vetAccountNumber = null, vetAccountNumber = null,
@@ -522,8 +392,6 @@ export const createBird = async ({
motivators: string | null; motivators: string | null;
demotivators: string | null; demotivators: string | null;
favoriteSnack: string | null; favoriteSnack: string | null;
locationLabel?: string | null;
locationDetails?: Record<string, unknown> | null;
vetClinicName?: string | null; vetClinicName?: string | null;
vetClinicAddress?: string | null; vetClinicAddress?: string | null;
vetAccountNumber?: string | null; vetAccountNumber?: string | null;
@@ -542,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, location_label, location_details, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth, gotcha_day, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled) `INSERT INTO birds (id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, 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) 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, location_label, location_details, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`, RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, 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,
@@ -554,8 +422,6 @@ export const createBird = async ({
motivators, motivators,
demotivators, demotivators,
favoriteSnack, favoriteSnack,
locationLabel,
locationDetails,
vetClinicName, vetClinicName,
vetClinicAddress, vetClinicAddress,
vetAccountNumber, vetAccountNumber,
@@ -587,8 +453,6 @@ export const updateBird = async ({
motivators, motivators,
demotivators, demotivators,
favoriteSnack, favoriteSnack,
locationLabel,
locationDetails,
vetClinicName, vetClinicName,
vetClinicAddress, vetClinicAddress,
vetAccountNumber, vetAccountNumber,
@@ -614,8 +478,6 @@ export const updateBird = async ({
motivators: string | null; motivators: string | null;
demotivators: string | null; demotivators: string | null;
favoriteSnack: string | null; favoriteSnack: string | null;
locationLabel: string | null;
locationDetails?: Record<string, unknown> | null;
vetClinicName: string | null; vetClinicName: string | null;
vetClinicAddress: string | null; vetClinicAddress: string | null;
vetAccountNumber: string | null; vetAccountNumber: string | null;
@@ -641,28 +503,26 @@ export const updateBird = async ({
motivators = $5, motivators = $5,
demotivators = $6, demotivators = $6,
favorite_snack = $7, favorite_snack = $7,
location_label = $8, vet_clinic_name = $8,
vet_clinic_name = $9, vet_clinic_address = $9,
vet_clinic_address = $10, vet_account_number = $10,
vet_account_number = $11, vet_doctor_name = $11,
vet_doctor_name = $12, gender = $12,
gender = $13, date_of_birth = $13,
date_of_birth = $14, gotcha_day = $14,
gotcha_day = $15, chart_color = $15,
chart_color = $16, photo_data_url = $16,
photo_data_url = $17, photo_object_key = $17,
photo_object_key = $18, photo_content_type = $18,
photo_content_type = $19, photo_updated_at = $19,
photo_updated_at = $20, notify_on_dob = $20,
notify_on_dob = $21, notify_on_gotcha_day = $21,
notify_on_gotcha_day = $22, public_profile_code = $22,
public_profile_code = $23, public_profile_enabled = $23
public_profile_enabled = $24,
location_details = $25
WHERE id = $1 WHERE id = $1
AND workspace_id = $26 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, location_label, location_details, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, 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
@@ -685,7 +545,6 @@ export const updateBird = async ({
motivators, motivators,
demotivators, demotivators,
favoriteSnack, favoriteSnack,
locationLabel,
vetClinicName, vetClinicName,
vetClinicAddress, vetClinicAddress,
vetAccountNumber, vetAccountNumber,
@@ -702,7 +561,6 @@ export const updateBird = async ({
notifyOnGotchaDay, notifyOnGotchaDay,
publicProfileCode, publicProfileCode,
publicProfileEnabled, publicProfileEnabled,
locationDetails ?? null,
workspaceId, workspaceId,
], ],
); );
@@ -732,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, motivators, demotivators, favorite_snack, location_label, location_details, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, 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
@@ -768,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, motivators, demotivators, favorite_snack, location_label, location_details, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, 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
@@ -808,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, motivators, demotivators, favorite_snack, location_label, location_details, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, 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
@@ -904,17 +762,6 @@ export const completePendingBirdTransfersForOwner = async (ownerEmail: string, t
} }
await markPendingBirdTransferCompleted(transfer.id, targetWorkspaceId); await markPendingBirdTransferCompleted(transfer.id, targetWorkspaceId);
try {
await createBirdTimelineEvent({
birdId: bird.id,
eventType: 'transferred',
fromWorkspaceId: transfer.source_workspace_id,
toWorkspaceId: targetWorkspaceId,
createdByUserId: transfer.requested_by_user_id,
});
} catch (timelineError) {
console.error('Unable to write bird timeline event', timelineError);
}
completed += 1; completed += 1;
} catch (error) { } catch (error) {
failed += 1; failed += 1;
@@ -962,6 +809,22 @@ export const createBirdTransferCode = async ({
return result.rows[0] ?? null; 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) => { export const getOpenBirdTransferCode = async (code: string) => {
const result = await db.query< const result = await db.query<
BirdRow & { BirdRow & {
@@ -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);
};
-51
View File
@@ -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,8 +101,6 @@ export type BirdRow = {
motivators: string | null; motivators: string | null;
demotivators: string | null; demotivators: string | null;
favorite_snack: string | null; favorite_snack: string | null;
location_label: string | null;
location_details: Record<string, unknown> | null;
vet_clinic_name: string | null; vet_clinic_name: string | null;
vet_clinic_address: string | null; vet_clinic_address: string | null;
vet_account_number: string | null; vet_account_number: string | null;
@@ -205,26 +174,6 @@ export type BirdTransferCodeRow = {
created_at: string; created_at: string;
}; };
export type BirdTimelineEventType = 'profile_created' | 'transferred' | 'location_updated' | 'owner_changed' | 'manual_note';
export type BirdTimelineEventRow = {
id: string;
bird_id: string;
event_type: BirdTimelineEventType;
from_workspace_id: number | null;
to_workspace_id: number | null;
from_workspace_name: string | null;
to_workspace_name: string | null;
from_owner_email: string | null;
to_owner_email: string | null;
location_label: string | null;
location_details: Record<string, unknown> | null;
note: string | null;
event_date: string;
created_by_user_id: string | null;
created_at: string;
};
export type WeightRow = { export type WeightRow = {
id: string; id: string;
bird_id: string; bird_id: string;
+18 -2
View File
@@ -55,7 +55,6 @@ services:
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy} PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production} FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production}
BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production} BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production}
MAPBOX_ACCESS_TOKEN: ${MAPBOX_ACCESS_TOKEN:-}
ADMIN_EMAILS: ${ADMIN_EMAILS:-} ADMIN_EMAILS: ${ADMIN_EMAILS:-}
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app} RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee} RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
@@ -97,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
@@ -137,7 +142,6 @@ services:
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy} PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production} FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production}
BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production} BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production}
MAPBOX_ACCESS_TOKEN: ${MAPBOX_ACCESS_TOKEN:-}
ADMIN_EMAILS: ${ADMIN_EMAILS:-} ADMIN_EMAILS: ${ADMIN_EMAILS:-}
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app} RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee} RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
@@ -156,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:
@@ -167,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
+1 -3
View File
@@ -53,7 +53,6 @@ services:
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy} PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000} FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
BACKEND_URL: ${BACKEND_URL:-http://localhost:5000} BACKEND_URL: ${BACKEND_URL:-http://localhost:5000}
MAPBOX_ACCESS_TOKEN: ${MAPBOX_ACCESS_TOKEN:-}
ADMIN_EMAILS: ${ADMIN_EMAILS:-} ADMIN_EMAILS: ${ADMIN_EMAILS:-}
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app} RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee} RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
@@ -130,7 +129,6 @@ services:
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy} PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000} FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
BACKEND_URL: ${BACKEND_URL:-http://localhost:5000} BACKEND_URL: ${BACKEND_URL:-http://localhost:5000}
MAPBOX_ACCESS_TOKEN: ${MAPBOX_ACCESS_TOKEN:-}
ADMIN_EMAILS: ${ADMIN_EMAILS:-} ADMIN_EMAILS: ${ADMIN_EMAILS:-}
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app} RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee} RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
@@ -162,7 +160,7 @@ services:
dockerfile: Dockerfile.dev dockerfile: Dockerfile.dev
container_name: flockpal-frontend container_name: flockpal-frontend
environment: environment:
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-/api} VITE_API_BASE_URL: ${VITE_API_BASE_URL:-http://localhost:5000/api}
depends_on: depends_on:
- backend - backend
ports: ports:
+35 -2
View File
@@ -319,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`
-1
View File
@@ -8,7 +8,6 @@
type="image/svg+xml" type="image/svg+xml"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cdefs%3E%3ClinearGradient id='featherFill' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' stop-color='%23cb3a35'/%3E%3Cstop offset='30%25' stop-color='%23f0b63f'/%3E%3Cstop offset='58%25' stop-color='%23238a5a'/%3E%3Cstop offset='100%25' stop-color='%232769b3'/%3E%3C/linearGradient%3E%3C/defs%3E%3Cpath d='M50.8 10.4C37.9 10.3 27 18.5 22.7 31.1c-3.1 9.1-2.1 18.5-8.6 24.8c-1.5 1.5-0.2 4 1.9 3.6c8.4-1.5 14.6-6.7 18.6-13.7c1 0.5 2.2 0.8 3.4 0.8c3.5 0 6.5-2.3 7.5-5.4c1.9-0.4 3.7-1.3 5.1-2.7c2-2 3-4.6 3.1-7.2c3.3-5.8 4.9-12.9 1.4-20.2c-0.7-1.3-2-0.7-4.3-0.7Z' fill='url(%23featherFill)'/%3E%3Cpath d='M18 56c8.5-3.4 14.2-9.8 18.1-17.8M26.9 48.9c6.9-7.2 13.5-14.8 20.3-22.1M31.8 41.2c6.4-1.3 12.1-4.6 16.5-9.4M36.8 33.8c4.9-0.9 9.2-3.4 12.6-7.1' fill='none' stroke='%23fff8ef' stroke-linecap='round' stroke-width='2.6'/%3E%3Cpath d='M18 56c8.5-3.4 14.2-9.8 18.1-17.8' fill='none' stroke='%2363562d' stroke-linecap='round' stroke-width='2.2'/%3E%3C/svg%3E" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cdefs%3E%3ClinearGradient id='featherFill' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' stop-color='%23cb3a35'/%3E%3Cstop offset='30%25' stop-color='%23f0b63f'/%3E%3Cstop offset='58%25' stop-color='%23238a5a'/%3E%3Cstop offset='100%25' stop-color='%232769b3'/%3E%3C/linearGradient%3E%3C/defs%3E%3Cpath d='M50.8 10.4C37.9 10.3 27 18.5 22.7 31.1c-3.1 9.1-2.1 18.5-8.6 24.8c-1.5 1.5-0.2 4 1.9 3.6c8.4-1.5 14.6-6.7 18.6-13.7c1 0.5 2.2 0.8 3.4 0.8c3.5 0 6.5-2.3 7.5-5.4c1.9-0.4 3.7-1.3 5.1-2.7c2-2 3-4.6 3.1-7.2c3.3-5.8 4.9-12.9 1.4-20.2c-0.7-1.3-2-0.7-4.3-0.7Z' fill='url(%23featherFill)'/%3E%3Cpath d='M18 56c8.5-3.4 14.2-9.8 18.1-17.8M26.9 48.9c6.9-7.2 13.5-14.8 20.3-22.1M31.8 41.2c6.4-1.3 12.1-4.6 16.5-9.4M36.8 33.8c4.9-0.9 9.2-3.4 12.6-7.1' fill='none' stroke='%23fff8ef' stroke-linecap='round' stroke-width='2.6'/%3E%3Cpath d='M18 56c8.5-3.4 14.2-9.8 18.1-17.8' fill='none' stroke='%2363562d' stroke-linecap='round' stroke-width='2.2'/%3E%3C/svg%3E"
/> />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0&icon_names=egg,manage_accounts,map,move,move_location,sticky_note_2,timeline" />
<title>FlockPal</title> <title>FlockPal</title>
</head> </head>
<body> <body>
+6
View File
@@ -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;
} }
+654 -1486
View File
File diff suppressed because it is too large Load Diff
+11 -487
View File
@@ -616,6 +616,14 @@ textarea {
break-inside: avoid; break-inside: avoid;
} }
.settings-card-bird-profiles {
order: 1;
}
.settings-card-bird-profiles[hidden] {
display: none;
}
.settings-card-collaborators { .settings-card-collaborators {
order: 2; order: 2;
} }
@@ -742,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;
@@ -1324,281 +1215,6 @@ textarea {
align-items: center; align-items: center;
} }
.bird-timeline-card {
grid-template-columns: 18px minmax(0, 1fr);
gap: 0.75rem;
border: 1px solid rgba(39, 105, 179, 0.12);
border-radius: 8px;
background: rgba(255, 255, 255, 0.76);
}
.bird-timeline-graph-card {
padding: 0.85rem;
border: 1px solid rgba(39, 105, 179, 0.12);
border-radius: 8px;
background: rgba(255, 255, 255, 0.72);
}
.bird-timeline-graph {
position: relative;
min-height: 340px;
isolation: isolate;
}
.bird-timeline-graph-line {
position: absolute;
left: calc(8.125% + 18px);
right: 8.125%;
top: 50%;
height: 4px;
border-radius: 999px;
background: linear-gradient(90deg, rgba(39, 105, 179, 0.42), rgba(35, 138, 90, 0.88));
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: 26px;
height: 26px;
border: 4px solid var(--accent-green);
border-radius: 50%;
background: #fffdf9;
}
.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.owner_changed .bird-timeline-graph-dot {
border-color: var(--accent-blue);
}
.bird-timeline-graph-point.transferred .bird-timeline-graph-dot {
border-color: var(--accent-red);
}
.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 {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--accent-green);
font-family: "Material Symbols Outlined";
font-size: 18px;
font-style: normal;
font-weight: 400;
line-height: 1;
letter-spacing: 0;
text-transform: none;
white-space: nowrap;
direction: ltr;
font-variation-settings: "FILL" 0, "wght" 400, "GRAD" 0, "opsz" 24;
font-feature-settings: "liga";
-webkit-font-feature-settings: "liga";
-webkit-font-smoothing: antialiased;
}
.bird-timeline-graph-point.hatch_date .bird-timeline-graph-icon {
color: var(--accent-gold);
font-size: 34px;
}
.bird-timeline-graph-point.owner_changed .bird-timeline-graph-icon {
color: var(--accent-blue);
}
.bird-timeline-graph-point.transferred .bird-timeline-graph-icon {
color: var(--accent-red);
}
.bird-timeline-graph-copy {
position: absolute;
left: 50%;
width: 124px;
transform: translateX(-50%);
display: grid;
gap: 0.08rem;
justify-items: center;
text-align: center;
color: var(--ink);
font-size: 0.68rem;
}
.bird-timeline-graph-copy strong {
font-weight: 700;
line-height: 1.15;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
overflow-wrap: anywhere;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.bird-timeline-graph-copy span {
color: var(--muted);
line-height: 1.1;
}
.bird-timeline-graph-point.above .bird-timeline-graph-copy {
left: calc(50% + var(--branch-offset, 0px));
bottom: calc(var(--branch-distance, 34px) + 24px);
}
.bird-timeline-graph-point.below .bird-timeline-graph-copy {
left: calc(50% + var(--branch-offset, 0px));
top: calc(var(--branch-distance, 34px) + 24px);
}
.bird-timeline-graph-point.on-line .bird-timeline-graph-copy {
bottom: 28px;
}
.bird-timeline-form {
padding: 0.85rem;
border: 1px solid rgba(35, 138, 90, 0.14);
border-radius: 8px;
background: rgba(240, 248, 244, 0.54);
}
.bird-timeline-marker {
width: 12px;
height: 12px;
margin-top: 0.25rem;
border-radius: 999px;
background: var(--accent-green);
box-shadow: 0 0 0 4px rgba(35, 138, 90, 0.12);
}
.bird-timeline-content {
display: grid;
gap: 0.3rem;
min-width: 0;
}
.bird-timeline-content > div {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
}
.bird-timeline-content strong,
.bird-timeline-content span,
.bird-timeline-content small,
.bird-timeline-content p {
overflow-wrap: anywhere;
}
.bird-timeline-content span,
.bird-timeline-content small {
color: var(--muted);
}
.bird-timeline-content p {
margin: 0;
color: var(--ink);
}
.legend-grid, .legend-grid,
.detail-grid, .detail-grid,
.summary-grid { .summary-grid {
@@ -1698,7 +1314,6 @@ textarea {
.bird-detail-tab .info-tab-icon, .bird-detail-tab .info-tab-icon,
.bird-detail-tab .note-tab-icon, .bird-detail-tab .note-tab-icon,
.bird-detail-tab .report-tab-icon, .bird-detail-tab .report-tab-icon,
.bird-detail-tab .timeline-tab-icon,
.bird-detail-tab .audit-tab-icon, .bird-detail-tab .audit-tab-icon,
.bird-detail-tab .vet-tab-icon { .bird-detail-tab .vet-tab-icon {
width: 24px; width: 24px;
@@ -1707,25 +1322,6 @@ textarea {
stroke: none; stroke: none;
} }
.bird-detail-tab .timeline-tab-icon {
display: inline-flex;
align-items: center;
justify-content: center;
color: currentColor;
font-family: "Material Symbols Outlined";
font-size: 24px;
font-style: normal;
font-weight: 400;
line-height: 1;
letter-spacing: 0;
text-transform: none;
white-space: nowrap;
direction: ltr;
font-variation-settings: "FILL" 0, "wght" 400, "GRAD" 0, "opsz" 24;
-webkit-font-feature-settings: "liga";
-webkit-font-smoothing: antialiased;
}
.bird-detail-tab:hover { .bird-detail-tab:hover {
border-color: rgba(35, 138, 90, 0.28); border-color: rgba(35, 138, 90, 0.28);
color: var(--ink); color: var(--ink);
@@ -1746,7 +1342,7 @@ textarea {
.profile-copy { .profile-copy {
display: grid; display: grid;
gap: 0.18rem; gap: 0.3rem;
} }
.profile-copy h3 { .profile-copy h3 {
@@ -1754,10 +1350,6 @@ textarea {
font-size: 1.6rem; font-size: 1.6rem;
} }
.profile-copy p {
margin: 0;
}
.profile-title { .profile-title {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -2206,60 +1798,6 @@ label {
font-size: 0.95rem; font-size: 0.95rem;
} }
.verified-location-field {
display: grid;
gap: 0.75rem;
}
.verified-location-label {
display: grid;
gap: 0.35rem;
font-weight: 600;
}
.verified-location-search-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.65rem;
align-items: end;
}
.verified-location-search-row.has-selected-location {
grid-template-columns: minmax(0, 1fr) auto auto;
}
.verified-location-search-row.has-selected-location input {
border-color: rgba(35, 138, 90, 0.45);
background: rgba(35, 138, 90, 0.08);
box-shadow: 0 0 0 3px rgba(35, 138, 90, 0.08);
}
.verified-location-result small {
color: var(--muted);
}
.verified-location-results {
display: grid;
gap: 0.45rem;
}
.verified-location-result {
display: grid;
gap: 0.15rem;
width: 100%;
padding: 0.8rem 0.9rem;
text-align: left;
color: var(--ink);
border: 1px solid rgba(53, 129, 98, 0.2);
border-radius: 8px;
background: rgba(255, 255, 255, 0.55);
}
.verified-location-result:hover {
border-color: rgba(39, 105, 179, 0.28);
background: rgba(255, 255, 255, 0.78);
}
.toggle-card input[type="checkbox"] { .toggle-card input[type="checkbox"] {
width: 20px; width: 20px;
height: 20px; height: 20px;
@@ -2579,11 +2117,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,
@@ -2594,8 +2127,7 @@ label {
.inline-form, .inline-form,
.profile-hero, .profile-hero,
.photo-editor, .photo-editor,
.settings-nested-grid, .settings-nested-grid {
.verified-location-search-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -2672,25 +2204,17 @@ label {
} }
.page-tabs { .page-tabs {
grid-auto-flow: column; grid-template-columns: repeat(auto-fit, minmax(64px, 1fr));
grid-auto-columns: minmax(5.5rem, max-content);
grid-template-columns: none;
gap: 0.4rem; gap: 0.4rem;
min-width: 0;
overflow-x: auto;
padding-bottom: 0.1rem;
scrollbar-width: thin;
} }
.page-tab { .page-tab {
min-height: 42px; min-height: 42px;
min-width: 5.5rem;
padding: 0.55rem 0.65rem; padding: 0.55rem 0.65rem;
border-radius: 14px; border-radius: 14px;
text-align: center; text-align: center;
font-size: 0.92rem; font-size: 0.92rem;
font-weight: 700; font-weight: 700;
white-space: nowrap;
} }
.side-nav .secondary-button { .side-nav .secondary-button {
-6
View File
@@ -5,11 +5,5 @@ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
port: 3000, port: 3000,
proxy: {
'/api': {
target: 'http://backend:5000',
changeOrigin: true,
},
},
}, },
}); });