Compare commits
42
Commits
4a43a450f3
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1ca4891fc | ||
|
|
7406d00687 | ||
|
|
ecad447d60 | ||
|
|
3ba6410972 | ||
|
|
a6eac3009d | ||
|
|
737a84a8b8 | ||
|
|
18fd76dc1f | ||
|
|
f627157a14 | ||
|
|
35bd87b8b5 | ||
|
|
14bc1c30a0 | ||
|
|
d03672fcdd | ||
|
|
46e07336ef | ||
|
|
bcaa8c4464 | ||
|
|
84d850a1ba | ||
|
|
0dfacc0d17 | ||
|
|
7ef20ab0fb | ||
|
|
9ddd85b5c4 | ||
|
|
a988d9662b | ||
|
|
56068e02a3 | ||
|
|
1140be8f32 | ||
|
|
4d3ab0b143 | ||
|
|
1e98d55cb5 | ||
|
|
454adc6f5e | ||
|
|
ae8c4326b5 | ||
|
|
480bbe8fc7 | ||
|
|
bb589e3489 | ||
|
|
c3bec15c63 | ||
|
|
979a17132d | ||
|
|
cadbdc2a7f | ||
|
|
8e2f789e9b | ||
|
|
7e2d06c50b | ||
|
|
c2d518f864 | ||
|
|
c3297b5915 | ||
|
|
41dda33310 | ||
|
|
c98a5a2863 | ||
|
|
6c9017c3dc | ||
|
|
6b11a73579 | ||
|
|
1f26255ebd | ||
|
|
14cdfe603d | ||
|
|
d5bb87910e | ||
|
|
3053e3bef5 | ||
|
|
6ade13a8be |
@@ -14,6 +14,7 @@ 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
|
||||||
|
|||||||
@@ -48,6 +48,29 @@ jobs:
|
|||||||
cd /docker/FlockPal-dev
|
cd /docker/FlockPal-dev
|
||||||
docker compose -f docker-compose.dev.yaml up -d --build
|
docker compose -f docker-compose.dev.yaml up -d --build
|
||||||
|
|
||||||
|
- name: Notify Discord
|
||||||
|
if: always()
|
||||||
|
env:
|
||||||
|
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||||
|
JOB_STATUS: ${{ job.status }}
|
||||||
|
DEPLOY_ENVIRONMENT: dev
|
||||||
|
run: |
|
||||||
|
if [ -z "$DISCORD_WEBHOOK_URL" ]; then
|
||||||
|
echo "DISCORD_WEBHOOK_URL is not configured; skipping Discord notification."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$JOB_STATUS" = "success" ]; then
|
||||||
|
STATUS_LABEL="SUCCESS"
|
||||||
|
else
|
||||||
|
STATUS_LABEL="FAILED"
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl --fail --show-error --silent \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"content\":\"[${STATUS_LABEL}] FlockPal ${DEPLOY_ENVIRONMENT} deploy completed with status: ${JOB_STATUS}\\nBranch: ${{ github.ref_name }}\\nCommit: ${{ github.sha }}\\nRun: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\"}" \
|
||||||
|
"$DISCORD_WEBHOOK_URL" || echo "Discord notification failed."
|
||||||
|
|
||||||
deploy-prod:
|
deploy-prod:
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref_name == 'main') }}
|
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref_name == 'main') }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -85,4 +108,33 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
cd /docker/FlockPal
|
cd /docker/FlockPal
|
||||||
docker compose -f docker-compose.prod.yml up -d --build
|
docker compose -f docker-compose.prod.yml up -d --build --wait --wait-timeout 180
|
||||||
|
|
||||||
|
- name: Verify production API readiness
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
cd /docker/FlockPal
|
||||||
|
docker compose -f docker-compose.prod.yml exec -T backend node dist/healthcheck.js api-ready
|
||||||
|
|
||||||
|
- name: Notify Discord
|
||||||
|
if: always()
|
||||||
|
env:
|
||||||
|
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||||
|
JOB_STATUS: ${{ job.status }}
|
||||||
|
DEPLOY_ENVIRONMENT: production
|
||||||
|
run: |
|
||||||
|
if [ -z "$DISCORD_WEBHOOK_URL" ]; then
|
||||||
|
echo "DISCORD_WEBHOOK_URL is not configured; skipping Discord notification."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$JOB_STATUS" = "success" ]; then
|
||||||
|
STATUS_LABEL="SUCCESS"
|
||||||
|
else
|
||||||
|
STATUS_LABEL="FAILED"
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl --fail --show-error --silent \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"content\":\"[${STATUS_LABEL}] FlockPal ${DEPLOY_ENVIRONMENT} deploy completed with status: ${JOB_STATUS}\\nBranch: ${{ github.ref_name }}\\nCommit: ${{ github.sha }}\\nRun: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\"}" \
|
||||||
|
"$DISCORD_WEBHOOK_URL" || echo "Discord notification failed."
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ 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`
|
||||||
|
|||||||
+489
-185
@@ -14,7 +14,7 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import { db } from './db/client.js';
|
import { db } from './db/client.js';
|
||||||
import { ensureSchema } from './db/schema.js';
|
import { ensureSchema } from './db/schema.js';
|
||||||
import { adoptionReportQueueEvents, enqueueAdoptionReportJob, getAdoptionReportQueueCounts } from './queues/adoptionReportQueue.js';
|
import { adoptionReportQueueEvents, enqueueAdoptionReportJob } from './queues/adoptionReportQueue.js';
|
||||||
import { enqueueBirdMilestoneReminderJob, getBirdMilestoneReminderQueueCounts } from './queues/birdMilestoneReminderQueue.js';
|
import { enqueueBirdMilestoneReminderJob, getBirdMilestoneReminderQueueCounts } from './queues/birdMilestoneReminderQueue.js';
|
||||||
import { enqueueMedicationReminderJob, getMedicationReminderQueueCounts } from './queues/medicationReminderQueue.js';
|
import { enqueueMedicationReminderJob, getMedicationReminderQueueCounts } from './queues/medicationReminderQueue.js';
|
||||||
import {
|
import {
|
||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
completePendingBirdTransfersForOwner,
|
completePendingBirdTransfersForOwner,
|
||||||
createBird,
|
createBird,
|
||||||
createBirdMilestoneReminderDelivery,
|
createBirdMilestoneReminderDelivery,
|
||||||
|
createBirdTimelineEvent,
|
||||||
createMedicationReminderDelivery,
|
createMedicationReminderDelivery,
|
||||||
createBirdTransferCode,
|
createBirdTransferCode,
|
||||||
createMedicationForBird,
|
createMedicationForBird,
|
||||||
@@ -51,8 +52,8 @@ import {
|
|||||||
getBirdById,
|
getBirdById,
|
||||||
getBirdByPublicProfileCode,
|
getBirdByPublicProfileCode,
|
||||||
getOpenBirdTransferCode,
|
getOpenBirdTransferCode,
|
||||||
getOpenBirdTransferCodeForBird,
|
|
||||||
listBirds,
|
listBirds,
|
||||||
|
listBirdTimelineEvents,
|
||||||
listDueBirdMilestoneReminders,
|
listDueBirdMilestoneReminders,
|
||||||
listDueMedicationReminders,
|
listDueMedicationReminders,
|
||||||
listMemorializedBirds,
|
listMemorializedBirds,
|
||||||
@@ -98,7 +99,6 @@ import {
|
|||||||
getMembershipForUser,
|
getMembershipForUser,
|
||||||
getNextWorkspaceId,
|
getNextWorkspaceId,
|
||||||
getWorkspaceById,
|
getWorkspaceById,
|
||||||
getWorkspaceBirdCount,
|
|
||||||
getWorkspaceTotalBirdCount,
|
getWorkspaceTotalBirdCount,
|
||||||
listOwnedWorkspacesByOwnerEmail,
|
listOwnedWorkspacesByOwnerEmail,
|
||||||
listRescueWorkspacesForAdmin,
|
listRescueWorkspacesForAdmin,
|
||||||
@@ -121,6 +121,8 @@ import type {
|
|||||||
BirdGender,
|
BirdGender,
|
||||||
BirdMilestoneReminderCandidateRow,
|
BirdMilestoneReminderCandidateRow,
|
||||||
BirdRow,
|
BirdRow,
|
||||||
|
BirdTimelineEventType,
|
||||||
|
BirdTimelineEventRow,
|
||||||
FlockNoteRow,
|
FlockNoteRow,
|
||||||
IntegrationTokenRow,
|
IntegrationTokenRow,
|
||||||
LostBirdMatchRow,
|
LostBirdMatchRow,
|
||||||
@@ -270,6 +272,26 @@ const birdProfileListSchema = z
|
|||||||
.optional()
|
.optional()
|
||||||
.or(z.literal(''));
|
.or(z.literal(''));
|
||||||
|
|
||||||
|
const verifiedLocationDetailsSchema = z
|
||||||
|
.object({
|
||||||
|
label: z.string().trim().max(220).optional().or(z.literal('')),
|
||||||
|
city: z.string().trim().max(120).optional().or(z.literal('')),
|
||||||
|
region: z.string().trim().max(120).optional().or(z.literal('')),
|
||||||
|
country: z.string().trim().max(120).optional().or(z.literal('')),
|
||||||
|
countryCode: z.string().trim().max(2).optional().or(z.literal('')),
|
||||||
|
latitude: z.coerce.number().min(-90).max(90).optional().nullable().or(z.literal('')),
|
||||||
|
longitude: z.coerce.number().min(-180).max(180).optional().nullable().or(z.literal('')),
|
||||||
|
precision: z.enum(['city', 'region', 'country']).optional(),
|
||||||
|
provider: z.literal('mapbox').optional().nullable(),
|
||||||
|
providerPlaceId: z.string().trim().max(220).optional().or(z.literal('')),
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.nullable();
|
||||||
|
|
||||||
|
const locationSearchSchema = z.object({
|
||||||
|
q: z.string().trim().min(3).max(120),
|
||||||
|
});
|
||||||
|
|
||||||
const birdSchema = z.object({
|
const birdSchema = z.object({
|
||||||
name: z.string().trim().min(1).max(120),
|
name: z.string().trim().min(1).max(120),
|
||||||
tagId: z.string().trim().max(80).optional().or(z.literal('')),
|
tagId: z.string().trim().max(80).optional().or(z.literal('')),
|
||||||
@@ -277,11 +299,14 @@ const birdSchema = z.object({
|
|||||||
motivators: birdProfileListSchema,
|
motivators: birdProfileListSchema,
|
||||||
demotivators: birdProfileListSchema,
|
demotivators: birdProfileListSchema,
|
||||||
favoriteSnack: z.string().trim().max(160).optional().or(z.literal('')),
|
favoriteSnack: z.string().trim().max(160).optional().or(z.literal('')),
|
||||||
|
locationLabel: z.string().trim().max(160).optional().or(z.literal('')),
|
||||||
|
locationDetails: verifiedLocationDetailsSchema,
|
||||||
vetClinicName: z.string().trim().max(160).optional().or(z.literal('')),
|
vetClinicName: z.string().trim().max(160).optional().or(z.literal('')),
|
||||||
vetClinicAddress: z.string().trim().max(500).optional().or(z.literal('')),
|
vetClinicAddress: z.string().trim().max(500).optional().or(z.literal('')),
|
||||||
vetAccountNumber: z.string().trim().max(120).optional().or(z.literal('')),
|
vetAccountNumber: z.string().trim().max(120).optional().or(z.literal('')),
|
||||||
vetDoctorName: z.string().trim().max(160).optional().or(z.literal('')),
|
vetDoctorName: z.string().trim().max(160).optional().or(z.literal('')),
|
||||||
gender: birdGenderSchema.optional(),
|
gender: birdGenderSchema.optional(),
|
||||||
|
hatchDay: dateStringSchema.optional().or(z.literal('')),
|
||||||
dateOfBirth: dateStringSchema.optional().or(z.literal('')),
|
dateOfBirth: dateStringSchema.optional().or(z.literal('')),
|
||||||
gotchaDay: dateStringSchema.optional().or(z.literal('')),
|
gotchaDay: dateStringSchema.optional().or(z.literal('')),
|
||||||
chartColor: chartColorSchema.optional(),
|
chartColor: chartColorSchema.optional(),
|
||||||
@@ -301,6 +326,146 @@ const memorialReminderPreferenceSchema = z.object({
|
|||||||
notifyOnMemorialDay: z.boolean(),
|
notifyOnMemorialDay: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const birdTimelineEventSchema = z
|
||||||
|
.object({
|
||||||
|
eventType: z.enum(['location_updated', 'owner_changed', 'manual_note']),
|
||||||
|
eventDate: dateStringSchema.optional().or(z.literal('')),
|
||||||
|
locationLabel: z.string().trim().max(160).optional().or(z.literal('')),
|
||||||
|
locationDetails: verifiedLocationDetailsSchema,
|
||||||
|
note: z.string().trim().max(500).optional().or(z.literal('')),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(value) =>
|
||||||
|
value.eventType === 'owner_changed' ||
|
||||||
|
Boolean(
|
||||||
|
value.locationLabel?.trim() ||
|
||||||
|
value.note?.trim() ||
|
||||||
|
value.locationDetails?.city?.trim() ||
|
||||||
|
value.locationDetails?.region?.trim() ||
|
||||||
|
value.locationDetails?.country?.trim(),
|
||||||
|
),
|
||||||
|
'Add a location or note for this timeline item.',
|
||||||
|
);
|
||||||
|
|
||||||
|
type VerifiedLocationDetailsInput = z.infer<typeof verifiedLocationDetailsSchema>;
|
||||||
|
|
||||||
|
const normalizeVerifiedLocationDetails = (value: VerifiedLocationDetailsInput) => {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const city = value.city?.trim() || null;
|
||||||
|
const region = value.region?.trim() || null;
|
||||||
|
const country = value.country?.trim() || null;
|
||||||
|
const countryCode = value.countryCode?.trim().toUpperCase() || null;
|
||||||
|
const latitude = typeof value.latitude === 'number' ? Number(value.latitude.toFixed(4)) : null;
|
||||||
|
const longitude = typeof value.longitude === 'number' ? Number(value.longitude.toFixed(4)) : null;
|
||||||
|
const precision = value.precision ?? (city ? 'city' : region ? 'region' : country ? 'country' : null);
|
||||||
|
const label = value.label?.trim() || [city, region, country].filter(Boolean).join(', ') || null;
|
||||||
|
const provider = value.provider ?? null;
|
||||||
|
const providerPlaceId = value.providerPlaceId?.trim() || null;
|
||||||
|
|
||||||
|
if (!label && !city && !region && !country && !countryCode && latitude === null && longitude === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
city,
|
||||||
|
region,
|
||||||
|
country,
|
||||||
|
countryCode,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
precision,
|
||||||
|
provider,
|
||||||
|
providerPlaceId,
|
||||||
|
verifiedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatVerifiedLocationLabel = (details: ReturnType<typeof normalizeVerifiedLocationDetails>) =>
|
||||||
|
details ? details.label || [details.city, details.region, details.country].filter(Boolean).join(', ') || null : null;
|
||||||
|
|
||||||
|
type VerifiedLocationSearchResult = NonNullable<ReturnType<typeof normalizeVerifiedLocationDetails>>;
|
||||||
|
|
||||||
|
type MapboxGeocodeFeature = {
|
||||||
|
id?: string;
|
||||||
|
geometry?: {
|
||||||
|
coordinates?: [number, number];
|
||||||
|
};
|
||||||
|
properties?: {
|
||||||
|
mapbox_id?: string;
|
||||||
|
feature_type?: string;
|
||||||
|
name?: string;
|
||||||
|
full_address?: string;
|
||||||
|
place_formatted?: string;
|
||||||
|
coordinates?: {
|
||||||
|
longitude?: number;
|
||||||
|
latitude?: number;
|
||||||
|
};
|
||||||
|
context?: {
|
||||||
|
place?: { name?: string };
|
||||||
|
locality?: { name?: string };
|
||||||
|
region?: { name?: string; region_code?: string; region_code_full?: string };
|
||||||
|
country?: { name?: string; country_code?: string };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type MapboxGeocodeResponse = {
|
||||||
|
features?: MapboxGeocodeFeature[];
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapboxAccessToken = process.env.MAPBOX_ACCESS_TOKEN?.trim() ?? '';
|
||||||
|
const allowedMapboxLocationTypes = new Set(['place', 'locality', 'region', 'country']);
|
||||||
|
const mapboxLocationCache = new Map<string, { expiresAt: number; results: VerifiedLocationSearchResult[] }>();
|
||||||
|
const mapboxLocationCacheTtlMs = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const getMapboxContextName = (feature: MapboxGeocodeFeature, key: 'place' | 'locality' | 'region' | 'country') =>
|
||||||
|
feature.properties?.context?.[key]?.name?.trim() || null;
|
||||||
|
|
||||||
|
const normalizeMapboxLocationFeature = (feature: MapboxGeocodeFeature): VerifiedLocationSearchResult | null => {
|
||||||
|
const properties = feature.properties;
|
||||||
|
const featureType = properties?.feature_type;
|
||||||
|
|
||||||
|
if (!featureType || !allowedMapboxLocationTypes.has(featureType)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = properties.name?.trim() || null;
|
||||||
|
const contextPlace = getMapboxContextName(feature, 'place');
|
||||||
|
const contextLocality = getMapboxContextName(feature, 'locality');
|
||||||
|
const contextRegion = getMapboxContextName(feature, 'region');
|
||||||
|
const contextCountry = getMapboxContextName(feature, 'country');
|
||||||
|
const city = featureType === 'place' || featureType === 'locality' ? name : contextPlace || contextLocality;
|
||||||
|
const region = featureType === 'region' ? name : contextRegion;
|
||||||
|
const country = featureType === 'country' ? name : contextCountry;
|
||||||
|
const countryCode = properties.context?.country?.country_code?.trim().toUpperCase() || null;
|
||||||
|
const longitude = properties.coordinates?.longitude ?? feature.geometry?.coordinates?.[0] ?? null;
|
||||||
|
const latitude = properties.coordinates?.latitude ?? feature.geometry?.coordinates?.[1] ?? null;
|
||||||
|
const label = [city, region, country].filter(Boolean).join(', ') || properties.full_address || properties.place_formatted || name || null;
|
||||||
|
const precision = featureType === 'country' ? 'country' : featureType === 'region' ? 'region' : 'city';
|
||||||
|
|
||||||
|
if (!label || typeof latitude !== 'number' || typeof longitude !== 'number') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeVerifiedLocationDetails({
|
||||||
|
label,
|
||||||
|
city: city ?? '',
|
||||||
|
region: region ?? '',
|
||||||
|
country: country ?? '',
|
||||||
|
countryCode: countryCode ?? '',
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
precision,
|
||||||
|
provider: 'mapbox',
|
||||||
|
providerPlaceId: properties.mapbox_id || feature.id || '',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const weightSchema = z.object({
|
const weightSchema = z.object({
|
||||||
weightGrams: z.coerce.number().positive().max(10000),
|
weightGrams: z.coerce.number().positive().max(10000),
|
||||||
recordedOn: dateStringSchema,
|
recordedOn: dateStringSchema,
|
||||||
@@ -637,11 +802,14 @@ const normalizeBird = (row: BirdRow) => ({
|
|||||||
motivators: row.motivators,
|
motivators: row.motivators,
|
||||||
demotivators: row.demotivators,
|
demotivators: row.demotivators,
|
||||||
favoriteSnack: row.favorite_snack,
|
favoriteSnack: row.favorite_snack,
|
||||||
|
locationLabel: row.location_label,
|
||||||
|
locationDetails: row.location_details,
|
||||||
vetClinicName: row.vet_clinic_name,
|
vetClinicName: row.vet_clinic_name,
|
||||||
vetClinicAddress: row.vet_clinic_address,
|
vetClinicAddress: row.vet_clinic_address,
|
||||||
vetAccountNumber: row.vet_account_number,
|
vetAccountNumber: row.vet_account_number,
|
||||||
vetDoctorName: row.vet_doctor_name,
|
vetDoctorName: row.vet_doctor_name,
|
||||||
gender: row.gender,
|
gender: row.gender,
|
||||||
|
hatchDay: row.date_of_birth,
|
||||||
dateOfBirth: row.date_of_birth,
|
dateOfBirth: row.date_of_birth,
|
||||||
gotchaDay: row.gotcha_day,
|
gotchaDay: row.gotcha_day,
|
||||||
chartColor: row.chart_color,
|
chartColor: row.chart_color,
|
||||||
@@ -664,39 +832,13 @@ const normalizeBird = (row: BirdRow) => ({
|
|||||||
|
|
||||||
const createBirdTransferCodeValue = () => crypto.randomBytes(12).toString('base64url');
|
const createBirdTransferCodeValue = () => crypto.randomBytes(12).toString('base64url');
|
||||||
|
|
||||||
const ensureOpenBirdTransferCode = async (birdId: string, sourceWorkspaceId: number, requestedByUserId: string) => {
|
|
||||||
const existingTransferCode = await getOpenBirdTransferCodeForBird(birdId, sourceWorkspaceId);
|
|
||||||
|
|
||||||
if (existingTransferCode) {
|
|
||||||
return existingTransferCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
||||||
try {
|
|
||||||
return await createBirdTransferCode({
|
|
||||||
code: createBirdTransferCodeValue(),
|
|
||||||
birdId,
|
|
||||||
sourceWorkspaceId,
|
|
||||||
requestedByUserId,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (typeof error === 'object' && error && 'code' in error && error.code === '23505' && attempt < 2) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizePublicBirdProfile = (row: BirdRow) => ({
|
const normalizePublicBirdProfile = (row: BirdRow) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
workspaceId: row.workspace_id,
|
workspaceId: row.workspace_id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
favoriteSnack: row.favorite_snack,
|
favoriteSnack: row.favorite_snack,
|
||||||
gender: row.gender,
|
gender: row.gender,
|
||||||
|
hatchDay: row.date_of_birth,
|
||||||
dateOfBirth: row.date_of_birth,
|
dateOfBirth: row.date_of_birth,
|
||||||
photoDataUrl: getBirdPhotoUrl(row),
|
photoDataUrl: getBirdPhotoUrl(row),
|
||||||
});
|
});
|
||||||
@@ -771,6 +913,24 @@ const normalizeAuditLogEntry = (row: AuditLogEntryRow) => ({
|
|||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const normalizeBirdTimelineEvent = (row: BirdTimelineEventRow) => ({
|
||||||
|
id: row.id,
|
||||||
|
birdId: row.bird_id,
|
||||||
|
eventType: row.event_type,
|
||||||
|
fromWorkspaceId: row.from_workspace_id,
|
||||||
|
toWorkspaceId: row.to_workspace_id,
|
||||||
|
fromWorkspaceName: row.from_workspace_name,
|
||||||
|
toWorkspaceName: row.to_workspace_name,
|
||||||
|
fromOwnerEmail: row.from_owner_email,
|
||||||
|
toOwnerEmail: row.to_owner_email,
|
||||||
|
locationLabel: row.location_label,
|
||||||
|
locationDetails: row.location_details,
|
||||||
|
note: row.note,
|
||||||
|
eventDate: row.event_date,
|
||||||
|
createdByUserId: row.created_by_user_id,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
});
|
||||||
|
|
||||||
const normalizeIntegrationToken = (row: IntegrationTokenRow) => ({
|
const normalizeIntegrationToken = (row: IntegrationTokenRow) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
userId: row.user_id,
|
userId: row.user_id,
|
||||||
@@ -847,6 +1007,13 @@ const lostBirdReportLimiter = rateLimit({
|
|||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
message: { error: 'Too many found bird reports. Please try again later.' },
|
message: { error: 'Too many found bird reports. Please try again later.' },
|
||||||
});
|
});
|
||||||
|
const locationSearchLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
limit: 40,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: { error: 'Too many location searches. Please try again later.' },
|
||||||
|
});
|
||||||
app.post('/api/billing/stripe/webhook', express.raw({ type: 'application/json' }), async (req: Request, res: Response) => {
|
app.post('/api/billing/stripe/webhook', express.raw({ type: 'application/json' }), async (req: Request, res: Response) => {
|
||||||
if (!stripeWebhookSecret) {
|
if (!stripeWebhookSecret) {
|
||||||
res.status(503).json({ error: 'Stripe webhook is not configured.' });
|
res.status(503).json({ error: 'Stripe webhook is not configured.' });
|
||||||
@@ -993,26 +1160,6 @@ const subscriptionAllowsWrite = (workspace: WorkspaceRow) => {
|
|||||||
return workspace.subscription_status === 'active' || workspace.subscription_status === 'trialing';
|
return workspace.subscription_status === 'active' || workspace.subscription_status === 'trialing';
|
||||||
};
|
};
|
||||||
|
|
||||||
const getBillingPlanBirdLimit = (billingPlan: BillingPlan) => {
|
|
||||||
if (billingPlan === 'rescue_free') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (billingPlan === 'household_basic') {
|
|
||||||
return 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (billingPlan === 'household_plus') {
|
|
||||||
return 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (billingPlan === 'household_macaw') {
|
|
||||||
return 16;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapStripeSubscriptionStatus = (status: Stripe.Subscription.Status): SubscriptionStatus => {
|
const mapStripeSubscriptionStatus = (status: Stripe.Subscription.Status): SubscriptionStatus => {
|
||||||
if (status === 'active' || status === 'trialing' || status === 'past_due' || status === 'canceled' || status === 'unpaid') {
|
if (status === 'active' || status === 'trialing' || status === 'past_due' || status === 'canceled' || status === 'unpaid') {
|
||||||
return status;
|
return status;
|
||||||
@@ -1403,6 +1550,37 @@ const deleteBirdPhotoObjectIfNeeded = async (objectKey: string | null) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadBirdReportPhotoBuffer = async (bird: BirdRow) => {
|
||||||
|
if (!bird.photo_object_key) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const s3Config = getS3ImageStorageConfig();
|
||||||
|
|
||||||
|
if (!s3Config) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const signedUrl = getSignedS3ObjectUrl({
|
||||||
|
config: s3Config,
|
||||||
|
objectKey: bird.photo_object_key,
|
||||||
|
expiresInSeconds: 5 * 60,
|
||||||
|
});
|
||||||
|
const imageResponse = await fetch(signedUrl);
|
||||||
|
|
||||||
|
if (!imageResponse.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = imageResponse.headers.get('content-type') || bird.photo_content_type || '';
|
||||||
|
|
||||||
|
if (!/^image\/(?:png|jpe?g)$/i.test(contentType)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.from(await imageResponse.arrayBuffer());
|
||||||
|
};
|
||||||
|
|
||||||
const getDefaultBirdPhotoAttachment = () => {
|
const getDefaultBirdPhotoAttachment = () => {
|
||||||
const defaultPhotoPath = path.join(process.cwd(), 'assets', 'yoda-default.png');
|
const defaultPhotoPath = path.join(process.cwd(), 'assets', 'yoda-default.png');
|
||||||
|
|
||||||
@@ -2267,59 +2445,6 @@ const ensureBirdWritable = (bird: BirdRow, res: Response) => {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
type HealthCheckResult = {
|
|
||||||
ok: boolean;
|
|
||||||
latencyMs?: number;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const withHealthTimeout = async <T,>(operation: Promise<T>, timeoutMs = 2_000): Promise<T> => {
|
|
||||||
let timeout: NodeJS.Timeout | undefined;
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await Promise.race([
|
|
||||||
operation,
|
|
||||||
new Promise<never>((_resolve, reject) => {
|
|
||||||
timeout = setTimeout(() => reject(new Error('Health check timed out')), timeoutMs);
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
} finally {
|
|
||||||
if (timeout) {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const checkPostgresHealth = async (): Promise<HealthCheckResult> => {
|
|
||||||
const startedAt = Date.now();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await withHealthTimeout(db.query('SELECT 1'));
|
|
||||||
return { ok: true, latencyMs: Date.now() - startedAt };
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
latencyMs: Date.now() - startedAt,
|
|
||||||
error: error instanceof Error ? error.message : 'Postgres health check failed',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const checkRedisHealth = async (): Promise<HealthCheckResult> => {
|
|
||||||
const startedAt = Date.now();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await withHealthTimeout(getBirdMilestoneReminderQueueCounts());
|
|
||||||
return { ok: true, latencyMs: Date.now() - startedAt };
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
latencyMs: Date.now() - startedAt,
|
|
||||||
error: error instanceof Error ? error.message : 'Redis health check failed',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const writeAuditLog = async (
|
const writeAuditLog = async (
|
||||||
auth: AuthContext,
|
auth: AuthContext,
|
||||||
action: string,
|
action: string,
|
||||||
@@ -2343,51 +2468,69 @@ const writeAuditLog = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const writeBirdTimelineEvent = 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;
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
await createBirdTimelineEvent({
|
||||||
|
birdId,
|
||||||
|
eventType,
|
||||||
|
fromWorkspaceId,
|
||||||
|
toWorkspaceId,
|
||||||
|
locationLabel,
|
||||||
|
locationDetails,
|
||||||
|
note,
|
||||||
|
eventDate,
|
||||||
|
createdByUserId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Unable to write bird timeline event', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const isBillingOnlyWorkspaceUpdate = (
|
const isBillingOnlyWorkspaceUpdate = (
|
||||||
workspace: WorkspaceRow,
|
workspace: WorkspaceRow,
|
||||||
payload: z.infer<typeof workspaceSchema>,
|
payload: z.infer<typeof workspaceSchema>,
|
||||||
) => workspace.workspace_type === 'standard' && payload.workspaceType === 'standard' && payload.name === workspace.name;
|
) => workspace.workspace_type === 'standard' && payload.workspaceType === 'standard' && payload.name === workspace.name;
|
||||||
|
|
||||||
app.get('/api/health/live', (_req: Request, res: Response) => {
|
app.get('/api/health', (_req: Request, res: Response) => {
|
||||||
res.json({
|
res.json({ ok: true });
|
||||||
ok: true,
|
|
||||||
service: 'flockpal-backend',
|
|
||||||
status: 'live',
|
|
||||||
uptimeSeconds: Math.round(process.uptime()),
|
|
||||||
checkedAt: new Date().toISOString(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/health/live', (_req: Request, res: Response) => {
|
||||||
|
res.json({ ok: true, status: 'live' });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/health/ready', async (_req: Request, res: Response) => {
|
app.get('/api/health/ready', async (_req: Request, res: Response) => {
|
||||||
const [postgres, redis] = await Promise.all([checkPostgresHealth(), checkRedisHealth()]);
|
try {
|
||||||
const ok = postgres.ok && redis.ok;
|
await Promise.all([
|
||||||
|
db.query('SELECT 1'),
|
||||||
res.status(ok ? 200 : 503).json({
|
getBirdMilestoneReminderQueueCounts(),
|
||||||
ok,
|
getMedicationReminderQueueCounts(),
|
||||||
service: 'flockpal-backend',
|
]);
|
||||||
status: ok ? 'ready' : 'degraded',
|
res.json({ ok: true, status: 'ready' });
|
||||||
checkedAt: new Date().toISOString(),
|
} catch (error) {
|
||||||
dependencies: {
|
console.error('Readiness check failed', error);
|
||||||
postgres,
|
res.status(503).json({ ok: false, status: 'not_ready' });
|
||||||
redis,
|
}
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/api/health', async (_req: Request, res: Response) => {
|
|
||||||
const [postgres, redis] = await Promise.all([checkPostgresHealth(), checkRedisHealth()]);
|
|
||||||
const ok = postgres.ok && redis.ok;
|
|
||||||
|
|
||||||
res.status(ok ? 200 : 503).json({
|
|
||||||
ok,
|
|
||||||
service: 'flockpal-backend',
|
|
||||||
status: ok ? 'ready' : 'degraded',
|
|
||||||
checkedAt: new Date().toISOString(),
|
|
||||||
dependencies: {
|
|
||||||
postgres,
|
|
||||||
redis,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/metrics', requireAuth, requireAdmin, async (_req: Request, res: Response, next: NextFunction) => {
|
app.get('/api/metrics', requireAuth, requireAdmin, async (_req: Request, res: Response, next: NextFunction) => {
|
||||||
@@ -2419,7 +2562,6 @@ app.get('/api/metrics', requireAuth, requireAdmin, async (_req: Request, res: Re
|
|||||||
queues: {
|
queues: {
|
||||||
birdMilestoneReminders: birdMilestoneReminderQueueCounts,
|
birdMilestoneReminders: birdMilestoneReminderQueueCounts,
|
||||||
medicationReminders: medicationReminderQueueCounts,
|
medicationReminders: medicationReminderQueueCounts,
|
||||||
adoptionReports: await getAdoptionReportQueueCounts(),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2814,6 +2956,12 @@ app.get('/api/admin/summary', requireAuth, requireSessionAuth, requireAdmin, asy
|
|||||||
rescueBirds: Number(summary?.rescue_birds ?? 0),
|
rescueBirds: Number(summary?.rescue_birds ?? 0),
|
||||||
pendingRescues: Number(summary?.pending_rescues ?? 0),
|
pendingRescues: Number(summary?.pending_rescues ?? 0),
|
||||||
dailyUsers: Number(summary?.daily_users ?? 0),
|
dailyUsers: Number(summary?.daily_users ?? 0),
|
||||||
|
subscriptionsByPlan: {
|
||||||
|
household_basic: Number(summary?.household_basic_subscriptions ?? 0),
|
||||||
|
household_plus: Number(summary?.household_plus_subscriptions ?? 0),
|
||||||
|
household_macaw: Number(summary?.household_macaw_subscriptions ?? 0),
|
||||||
|
household_hyacinth_macaw: Number(summary?.household_hyacinth_macaw_subscriptions ?? 0),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -3433,6 +3581,61 @@ app.get('/api/audit-log', requireAuth, requireSessionAuth, requireWorkspaceRole(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/locations/search', requireAuth, locationSearchLimiter, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const parsed = locationSearchSchema.safeParse(req.query);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'Enter at least 3 characters to search for a location.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mapboxAccessToken) {
|
||||||
|
res.status(503).json({ error: 'Location search is not configured.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = parsed.data.q.replace(/\s+/g, ' ').trim();
|
||||||
|
const cacheKey = query.toLocaleLowerCase();
|
||||||
|
const cached = mapboxLocationCache.get(cacheKey);
|
||||||
|
|
||||||
|
if (cached && cached.expiresAt > Date.now()) {
|
||||||
|
res.json({ results: cached.results });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const searchUrl = new URL('https://api.mapbox.com/search/geocode/v6/forward');
|
||||||
|
searchUrl.searchParams.set('q', query);
|
||||||
|
searchUrl.searchParams.set('access_token', mapboxAccessToken);
|
||||||
|
searchUrl.searchParams.set('autocomplete', 'false');
|
||||||
|
searchUrl.searchParams.set('types', 'place,locality,region,country');
|
||||||
|
searchUrl.searchParams.set('limit', '3');
|
||||||
|
searchUrl.searchParams.set('permanent', 'true');
|
||||||
|
|
||||||
|
const mapboxResponse = await fetch(searchUrl);
|
||||||
|
const data = (await mapboxResponse.json().catch(() => null)) as MapboxGeocodeResponse | null;
|
||||||
|
|
||||||
|
if (!mapboxResponse.ok) {
|
||||||
|
res.status(502).json({ error: data?.message || 'Location search failed.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = (data?.features ?? [])
|
||||||
|
.map(normalizeMapboxLocationFeature)
|
||||||
|
.filter((result): result is VerifiedLocationSearchResult => result !== null)
|
||||||
|
.filter((result, index, allResults) => allResults.findIndex((entry) => entry.providerPlaceId === result.providerPlaceId) === index);
|
||||||
|
|
||||||
|
mapboxLocationCache.set(cacheKey, {
|
||||||
|
expiresAt: Date.now() + mapboxLocationCacheTtlMs,
|
||||||
|
results,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ results });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/birds', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
|
app.get('/api/birds', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const [birds, memorializedBirds] = await Promise.all([
|
const [birds, memorializedBirds] = await Promise.all([
|
||||||
@@ -3445,6 +3648,71 @@ app.get('/api/birds', requireAuth, async (req: Request, res: Response, next: Nex
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/birds/:birdId/timeline', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const bird = await getBirdById(req.params.birdId, req.auth!.workspace.id);
|
||||||
|
|
||||||
|
if (!bird) {
|
||||||
|
res.status(404).json({ error: 'Bird not found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = await listBirdTimelineEvents(req.params.birdId, req.auth!.workspace.id);
|
||||||
|
res.json({ events: events.map(normalizeBirdTimelineEvent) });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
'/api/birds/:birdId/timeline',
|
||||||
|
requireAuth,
|
||||||
|
requireWriteAccess,
|
||||||
|
requireSessionAuth,
|
||||||
|
requireWorkspaceRole(['owner', 'assistant', 'caregiver']),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const parsed = birdTimelineEventSchema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'Invalid timeline payload', details: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bird = await getBirdById(req.params.birdId, req.auth!.workspace.id);
|
||||||
|
|
||||||
|
if (!bird) {
|
||||||
|
res.status(404).json({ error: 'Bird not found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ensureBirdWritable(bird, res)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const locationDetails = normalizeVerifiedLocationDetails(parsed.data.locationDetails);
|
||||||
|
const event = await createBirdTimelineEvent({
|
||||||
|
birdId: bird.id,
|
||||||
|
eventType: parsed.data.eventType as BirdTimelineEventType,
|
||||||
|
toWorkspaceId: req.auth!.workspace.id,
|
||||||
|
locationLabel: formatVerifiedLocationLabel(locationDetails) ?? emptyToNull(parsed.data.locationLabel),
|
||||||
|
locationDetails,
|
||||||
|
note: emptyToNull(parsed.data.note),
|
||||||
|
eventDate: emptyToNull(parsed.data.eventDate),
|
||||||
|
createdByUserId: req.auth!.user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await writeAuditLog(req.auth!, 'bird.timeline_event_created', 'bird', bird.id, bird.name, {
|
||||||
|
eventType: parsed.data.eventType,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({ event: normalizeBirdTimelineEvent(event!) });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
app.get('/api/birds/:birdId/photo', async (req: Request, res: Response, next: NextFunction) => {
|
app.get('/api/birds/:birdId/photo', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const token = typeof req.query.token === 'string' ? req.query.token : '';
|
const token = typeof req.query.token === 'string' ? req.query.token : '';
|
||||||
@@ -3516,29 +3784,13 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
|
|||||||
let uploadedObjectKeyToCleanup: string | null = null;
|
let uploadedObjectKeyToCleanup: string | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const birdLimit = getBillingPlanBirdLimit(req.auth!.workspace.billing_plan);
|
|
||||||
|
|
||||||
if (birdLimit !== null) {
|
|
||||||
const currentBirdCount = await getWorkspaceBirdCount(req.auth!.workspace.id);
|
|
||||||
|
|
||||||
if (currentBirdCount >= birdLimit) {
|
|
||||||
res.status(409).json({
|
|
||||||
error: 'This flock has reached the bird limit for the selected plan. Upgrade the flock subscription or memorialize a bird before adding another.',
|
|
||||||
code: 'billing_plan_bird_limit_reached',
|
|
||||||
birdLimit,
|
|
||||||
currentBirdCount,
|
|
||||||
billingPlan: req.auth!.workspace.billing_plan,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const birdId = crypto.randomUUID();
|
const birdId = crypto.randomUUID();
|
||||||
const photoStorage = await resolveBirdPhotoStorage({
|
const photoStorage = await resolveBirdPhotoStorage({
|
||||||
birdId,
|
birdId,
|
||||||
workspaceId: req.auth!.workspace.id,
|
workspaceId: req.auth!.workspace.id,
|
||||||
photoDataUrl: emptyToNull(parsed.data.photoDataUrl),
|
photoDataUrl: emptyToNull(parsed.data.photoDataUrl),
|
||||||
});
|
});
|
||||||
|
const locationDetails = normalizeVerifiedLocationDetails(parsed.data.locationDetails);
|
||||||
uploadedObjectKeyToCleanup = photoStorage.photoObjectKey;
|
uploadedObjectKeyToCleanup = photoStorage.photoObjectKey;
|
||||||
const bird = await createBird({
|
const bird = await createBird({
|
||||||
birdId,
|
birdId,
|
||||||
@@ -3549,12 +3801,14 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
|
|||||||
motivators: emptyToNull(parsed.data.motivators),
|
motivators: emptyToNull(parsed.data.motivators),
|
||||||
demotivators: emptyToNull(parsed.data.demotivators),
|
demotivators: emptyToNull(parsed.data.demotivators),
|
||||||
favoriteSnack: emptyToNull(parsed.data.favoriteSnack),
|
favoriteSnack: emptyToNull(parsed.data.favoriteSnack),
|
||||||
|
locationLabel: formatVerifiedLocationLabel(locationDetails) ?? emptyToNull(parsed.data.locationLabel),
|
||||||
|
locationDetails,
|
||||||
vetClinicName: emptyToNull(parsed.data.vetClinicName),
|
vetClinicName: emptyToNull(parsed.data.vetClinicName),
|
||||||
vetClinicAddress: emptyToNull(parsed.data.vetClinicAddress),
|
vetClinicAddress: emptyToNull(parsed.data.vetClinicAddress),
|
||||||
vetAccountNumber: emptyToNull(parsed.data.vetAccountNumber),
|
vetAccountNumber: emptyToNull(parsed.data.vetAccountNumber),
|
||||||
vetDoctorName: emptyToNull(parsed.data.vetDoctorName),
|
vetDoctorName: emptyToNull(parsed.data.vetDoctorName),
|
||||||
gender: (parsed.data.gender ?? 'unknown') as BirdGender,
|
gender: (parsed.data.gender ?? 'unknown') as BirdGender,
|
||||||
dateOfBirth: emptyToNull(parsed.data.dateOfBirth),
|
dateOfBirth: emptyToNull(parsed.data.hatchDay || parsed.data.dateOfBirth),
|
||||||
gotchaDay: emptyToNull(parsed.data.gotchaDay),
|
gotchaDay: emptyToNull(parsed.data.gotchaDay),
|
||||||
chartColor: parsed.data.chartColor ?? '#cb3a35',
|
chartColor: parsed.data.chartColor ?? '#cb3a35',
|
||||||
photoDataUrl: photoStorage.photoDataUrl,
|
photoDataUrl: photoStorage.photoDataUrl,
|
||||||
@@ -3572,6 +3826,14 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
|
|||||||
species: bird!.species,
|
species: bird!.species,
|
||||||
tagId: bird!.tag_id,
|
tagId: bird!.tag_id,
|
||||||
});
|
});
|
||||||
|
await writeBirdTimelineEvent({
|
||||||
|
birdId: bird!.id,
|
||||||
|
eventType: 'profile_created',
|
||||||
|
toWorkspaceId: req.auth!.workspace.id,
|
||||||
|
locationLabel: bird!.location_label,
|
||||||
|
locationDetails: bird!.location_details,
|
||||||
|
createdByUserId: req.auth!.user.id,
|
||||||
|
});
|
||||||
res.status(201).json({ bird: normalizeBird(bird!) });
|
res.status(201).json({ bird: normalizeBird(bird!) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup);
|
await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup);
|
||||||
@@ -3656,6 +3918,13 @@ app.post('/api/birds/:birdId/transfer', requireAuth, requireWriteAccess, require
|
|||||||
destinationOwnerEmail,
|
destinationOwnerEmail,
|
||||||
destinationWorkspaceId: targetWorkspace.id,
|
destinationWorkspaceId: targetWorkspace.id,
|
||||||
});
|
});
|
||||||
|
await writeBirdTimelineEvent({
|
||||||
|
birdId: bird.id,
|
||||||
|
eventType: 'transferred',
|
||||||
|
fromWorkspaceId: req.auth!.workspace.id,
|
||||||
|
toWorkspaceId: targetWorkspace.id,
|
||||||
|
createdByUserId: req.auth!.user.id,
|
||||||
|
});
|
||||||
res.json({ bird: normalizeBird(bird), destinationOwnerEmail, destinationWorkspace: normalizeWorkspace(targetWorkspace) });
|
res.json({ bird: normalizeBird(bird), destinationOwnerEmail, destinationWorkspace: normalizeWorkspace(targetWorkspace) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
|
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
|
||||||
@@ -3686,7 +3955,25 @@ app.post(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const transferCode = await ensureOpenBirdTransferCode(sourceBird.id, req.auth!.workspace.id, req.auth!.user.id);
|
let transferCode = null;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||||
|
try {
|
||||||
|
transferCode = await createBirdTransferCode({
|
||||||
|
code: createBirdTransferCodeValue(),
|
||||||
|
birdId: sourceBird.id,
|
||||||
|
sourceWorkspaceId: req.auth!.workspace.id,
|
||||||
|
requestedByUserId: req.auth!.user.id,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
if (typeof error === 'object' && error && 'code' in error && error.code === '23505' && attempt < 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!transferCode) {
|
if (!transferCode) {
|
||||||
throw new Error('Unable to create bird transfer code.');
|
throw new Error('Unable to create bird transfer code.');
|
||||||
@@ -3708,30 +3995,6 @@ app.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
app.get('/api/birds/:birdId/transfer-code', requireAuth, requireWriteAccess, requireSessionAuth, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => {
|
|
||||||
try {
|
|
||||||
const sourceBird = await getBirdById(req.params.birdId, req.auth!.workspace.id);
|
|
||||||
|
|
||||||
if (!sourceBird) {
|
|
||||||
res.status(404).json({ error: 'Bird not found.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const transferCode = await getOpenBirdTransferCodeForBird(sourceBird.id, req.auth!.workspace.id);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
transferCode: transferCode
|
|
||||||
? {
|
|
||||||
code: transferCode.code,
|
|
||||||
bird: normalizeBird(sourceBird),
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post(
|
app.post(
|
||||||
'/api/bird-transfer-codes/:code/accept',
|
'/api/bird-transfer-codes/:code/accept',
|
||||||
requireAuth,
|
requireAuth,
|
||||||
@@ -3772,6 +4035,13 @@ app.post(
|
|||||||
sourceWorkspaceName: transferCode.workspace_name,
|
sourceWorkspaceName: transferCode.workspace_name,
|
||||||
transferCodeId: transferCode.transfer_code_id,
|
transferCodeId: transferCode.transfer_code_id,
|
||||||
});
|
});
|
||||||
|
await writeBirdTimelineEvent({
|
||||||
|
birdId: bird.id,
|
||||||
|
eventType: 'transferred',
|
||||||
|
fromWorkspaceId: transferCode.source_workspace_id,
|
||||||
|
toWorkspaceId: req.auth!.workspace.id,
|
||||||
|
createdByUserId: req.auth!.user.id,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({ bird: normalizeBird(bird), sourceWorkspaceName: transferCode.workspace_name, workspace: normalizeWorkspace(req.auth!.workspace) });
|
res.json({ bird: normalizeBird(bird), sourceWorkspaceName: transferCode.workspace_name, workspace: normalizeWorkspace(req.auth!.workspace) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -3804,7 +4074,25 @@ app.post(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const transferCode = await ensureOpenBirdTransferCode(sourceBird.id, req.auth!.workspace.id, req.auth!.user.id);
|
let transferCode = null;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||||
|
try {
|
||||||
|
transferCode = await createBirdTransferCode({
|
||||||
|
code: createBirdTransferCodeValue(),
|
||||||
|
birdId: sourceBird.id,
|
||||||
|
sourceWorkspaceId: req.auth!.workspace.id,
|
||||||
|
requestedByUserId: req.auth!.user.id,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
if (typeof error === 'object' && error && 'code' in error && error.code === '23505' && attempt < 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!transferCode) {
|
if (!transferCode) {
|
||||||
throw new Error('Unable to create bird transfer code.');
|
throw new Error('Unable to create bird transfer code.');
|
||||||
@@ -3872,6 +4160,7 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
|
|||||||
});
|
});
|
||||||
uploadedObjectKeyToCleanup =
|
uploadedObjectKeyToCleanup =
|
||||||
photoStorage.photoObjectKey && photoStorage.photoObjectKey !== existingBird.photo_object_key ? photoStorage.photoObjectKey : null;
|
photoStorage.photoObjectKey && photoStorage.photoObjectKey !== existingBird.photo_object_key ? photoStorage.photoObjectKey : null;
|
||||||
|
const locationDetails = normalizeVerifiedLocationDetails(parsed.data.locationDetails);
|
||||||
const bird = await updateBird({
|
const bird = await updateBird({
|
||||||
birdId: req.params.birdId,
|
birdId: req.params.birdId,
|
||||||
workspaceId: req.auth!.workspace.id,
|
workspaceId: req.auth!.workspace.id,
|
||||||
@@ -3881,12 +4170,14 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
|
|||||||
motivators: emptyToNull(parsed.data.motivators),
|
motivators: emptyToNull(parsed.data.motivators),
|
||||||
demotivators: emptyToNull(parsed.data.demotivators),
|
demotivators: emptyToNull(parsed.data.demotivators),
|
||||||
favoriteSnack: emptyToNull(parsed.data.favoriteSnack),
|
favoriteSnack: emptyToNull(parsed.data.favoriteSnack),
|
||||||
|
locationLabel: formatVerifiedLocationLabel(locationDetails) ?? emptyToNull(parsed.data.locationLabel),
|
||||||
|
locationDetails,
|
||||||
vetClinicName: emptyToNull(parsed.data.vetClinicName),
|
vetClinicName: emptyToNull(parsed.data.vetClinicName),
|
||||||
vetClinicAddress: emptyToNull(parsed.data.vetClinicAddress),
|
vetClinicAddress: emptyToNull(parsed.data.vetClinicAddress),
|
||||||
vetAccountNumber: emptyToNull(parsed.data.vetAccountNumber),
|
vetAccountNumber: emptyToNull(parsed.data.vetAccountNumber),
|
||||||
vetDoctorName: emptyToNull(parsed.data.vetDoctorName),
|
vetDoctorName: emptyToNull(parsed.data.vetDoctorName),
|
||||||
gender: (parsed.data.gender ?? 'unknown') as BirdGender,
|
gender: (parsed.data.gender ?? 'unknown') as BirdGender,
|
||||||
dateOfBirth: emptyToNull(parsed.data.dateOfBirth),
|
dateOfBirth: emptyToNull(parsed.data.hatchDay || parsed.data.dateOfBirth),
|
||||||
gotchaDay: emptyToNull(parsed.data.gotchaDay),
|
gotchaDay: emptyToNull(parsed.data.gotchaDay),
|
||||||
chartColor: parsed.data.chartColor ?? '#cb3a35',
|
chartColor: parsed.data.chartColor ?? '#cb3a35',
|
||||||
photoDataUrl: photoStorage.photoDataUrl,
|
photoDataUrl: photoStorage.photoDataUrl,
|
||||||
@@ -3910,6 +4201,19 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
|
|||||||
previousName: existingBird.name,
|
previousName: existingBird.name,
|
||||||
species: bird.species,
|
species: bird.species,
|
||||||
});
|
});
|
||||||
|
if (
|
||||||
|
(existingBird.location_label ?? '') !== (bird.location_label ?? '') ||
|
||||||
|
JSON.stringify(existingBird.location_details ?? null) !== JSON.stringify(bird.location_details ?? null)
|
||||||
|
) {
|
||||||
|
await writeBirdTimelineEvent({
|
||||||
|
birdId: bird.id,
|
||||||
|
eventType: 'location_updated',
|
||||||
|
toWorkspaceId: req.auth!.workspace.id,
|
||||||
|
locationLabel: bird.location_label,
|
||||||
|
locationDetails: bird.location_details,
|
||||||
|
createdByUserId: req.auth!.user.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
res.json({ bird: normalizeBird(bird) });
|
res.json({ bird: normalizeBird(bird) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup);
|
await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup);
|
||||||
|
|||||||
@@ -215,6 +215,8 @@ 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),
|
||||||
@@ -243,6 +245,8 @@ 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),
|
||||||
@@ -368,6 +372,32 @@ 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,
|
||||||
|
|||||||
@@ -40,5 +40,3 @@ 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');
|
|
||||||
|
|||||||
@@ -103,26 +103,10 @@ const fitText = (doc: PDFKit.PDFDocument, text: string, x: number, y: number, wi
|
|||||||
return doc.y;
|
return doc.y;
|
||||||
};
|
};
|
||||||
|
|
||||||
const measureFactHeight = (doc: PDFKit.PDFDocument, value: string, width: number, minHeight = 43) => {
|
const drawFact = (doc: PDFKit.PDFDocument, label: string, value: string, x: number, y: number, width: number) => {
|
||||||
doc.font('Helvetica-Bold').fontSize(10);
|
doc.roundedRect(x, y, width, 43, 6).fillAndStroke(colors.panel, colors.border);
|
||||||
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, {
|
doc.fillColor(colors.ink).fontSize(10).font('Helvetica-Bold').text(value, x + 8, y + 21, { width: width - 16, ellipsis: true });
|
||||||
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) => {
|
||||||
@@ -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 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() - 13);
|
startDate.setUTCDate(startDate.getUTCDate() - 29);
|
||||||
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;
|
||||||
@@ -377,14 +357,16 @@ export const renderAdoptionReportPdf = async ({
|
|||||||
y = page.margin;
|
y = page.margin;
|
||||||
}
|
}
|
||||||
y = drawSectionTitle(doc, 'Veterinary Clinic Info', y);
|
y = drawSectionTitle(doc, 'Veterinary Clinic Info', y);
|
||||||
drawFact(doc, 'Clinic name', bird.vet_clinic_name || 'Not recorded', page.margin, y, factWidth);
|
const vetFacts = [
|
||||||
drawFact(doc, 'Account #', bird.vet_account_number || 'Not recorded', page.margin + factWidth + factGap, y, factWidth);
|
['Clinic name', bird.vet_clinic_name || 'Not recorded'],
|
||||||
y += 50;
|
['Clinic address', bird.vet_clinic_address || 'Not recorded'],
|
||||||
const clinicAddressHeight = measureFactHeight(doc, bird.vet_clinic_address || 'Not recorded', contentWidth, 58);
|
['Account #', bird.vet_account_number || 'Not recorded'],
|
||||||
drawFact(doc, 'Clinic address', bird.vet_clinic_address || 'Not recorded', page.margin, y, contentWidth, clinicAddressHeight);
|
['Dr. name', bird.vet_doctor_name || 'Not recorded'],
|
||||||
y += clinicAddressHeight + 7;
|
];
|
||||||
drawFact(doc, 'Dr. name', bird.vet_doctor_name || 'Not recorded', page.margin, y, factWidth);
|
vetFacts.forEach(([label, value], index) => {
|
||||||
y += 50;
|
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 = drawSectionTitle(doc, 'Vet Visit History', y);
|
||||||
y = drawTable(
|
y = drawTable(
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
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';
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
createPendingBirdTransfer,
|
createPendingBirdTransfer,
|
||||||
getBirdById,
|
getBirdById,
|
||||||
getOpenBirdTransferCode,
|
getOpenBirdTransferCode,
|
||||||
getOpenBirdTransferCodeForBird,
|
|
||||||
listWeightsForBird,
|
listWeightsForBird,
|
||||||
markBirdTransferCodeCompleted,
|
markBirdTransferCodeCompleted,
|
||||||
transferBirdToWorkspace,
|
transferBirdToWorkspace,
|
||||||
@@ -191,6 +190,46 @@ 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);
|
||||||
@@ -200,6 +239,21 @@ test('completePendingBirdTransfersForOwner moves pending birds and marks complet
|
|||||||
assert.deepEqual(calls[1].params, ['bird-1', 10, 22]);
|
assert.deepEqual(calls[1].params, ['bird-1', 10, 22]);
|
||||||
assert.deepEqual(calls[2].params, ['transfer-1', 22]);
|
assert.deepEqual(calls[2].params, ['transfer-1', 22]);
|
||||||
assert.match(calls[2].text, /completed_at = CURRENT_TIMESTAMP/);
|
assert.match(calls[2].text, /completed_at = CURRENT_TIMESTAMP/);
|
||||||
|
assert.deepEqual(calls[5].params, [
|
||||||
|
'bird-1',
|
||||||
|
'transferred',
|
||||||
|
10,
|
||||||
|
22,
|
||||||
|
'Original Flock',
|
||||||
|
'Receiving Flock',
|
||||||
|
'sender@example.com',
|
||||||
|
'receiver@example.com',
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
'user-1',
|
||||||
|
null,
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getOpenBirdTransferCode only returns unconsumed codes', async () => {
|
test('getOpenBirdTransferCode only returns unconsumed codes', async () => {
|
||||||
@@ -214,17 +268,6 @@ test('getOpenBirdTransferCode only returns unconsumed codes', async () => {
|
|||||||
assert.match(calls[0].text, /birds\.workspace_id = bird_transfer_codes\.source_workspace_id/);
|
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 () => {
|
test('markBirdTransferCodeCompleted consumes a code for the receiving workspace', async () => {
|
||||||
const { calls } = mockDb({ rowCount: 1, rows: [] });
|
const { calls } = mockDb({ rowCount: 1, rows: [] });
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import type {
|
|||||||
BirdMilestoneReminderDeliveryRow,
|
BirdMilestoneReminderDeliveryRow,
|
||||||
BirdMilestoneReminderType,
|
BirdMilestoneReminderType,
|
||||||
BirdRow,
|
BirdRow,
|
||||||
|
BirdTimelineEventRow,
|
||||||
|
BirdTimelineEventType,
|
||||||
BirdTransferCodeRow,
|
BirdTransferCodeRow,
|
||||||
LostBirdMatchRow,
|
LostBirdMatchRow,
|
||||||
MedicationAdministrationRow,
|
MedicationAdministrationRow,
|
||||||
@@ -26,6 +28,8 @@ 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,
|
||||||
@@ -51,6 +55,34 @@ 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
|
||||||
@@ -134,6 +166,102 @@ 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
|
||||||
@@ -367,6 +495,8 @@ 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,
|
||||||
@@ -392,6 +522,8 @@ 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;
|
||||||
@@ -410,9 +542,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, 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, 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)
|
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, 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, 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,
|
birdId ?? null,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -422,6 +554,8 @@ export const createBird = async ({
|
|||||||
motivators,
|
motivators,
|
||||||
demotivators,
|
demotivators,
|
||||||
favoriteSnack,
|
favoriteSnack,
|
||||||
|
locationLabel,
|
||||||
|
locationDetails,
|
||||||
vetClinicName,
|
vetClinicName,
|
||||||
vetClinicAddress,
|
vetClinicAddress,
|
||||||
vetAccountNumber,
|
vetAccountNumber,
|
||||||
@@ -453,6 +587,8 @@ export const updateBird = async ({
|
|||||||
motivators,
|
motivators,
|
||||||
demotivators,
|
demotivators,
|
||||||
favoriteSnack,
|
favoriteSnack,
|
||||||
|
locationLabel,
|
||||||
|
locationDetails,
|
||||||
vetClinicName,
|
vetClinicName,
|
||||||
vetClinicAddress,
|
vetClinicAddress,
|
||||||
vetAccountNumber,
|
vetAccountNumber,
|
||||||
@@ -478,6 +614,8 @@ 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;
|
||||||
@@ -503,26 +641,28 @@ export const updateBird = async ({
|
|||||||
motivators = $5,
|
motivators = $5,
|
||||||
demotivators = $6,
|
demotivators = $6,
|
||||||
favorite_snack = $7,
|
favorite_snack = $7,
|
||||||
vet_clinic_name = $8,
|
location_label = $8,
|
||||||
vet_clinic_address = $9,
|
vet_clinic_name = $9,
|
||||||
vet_account_number = $10,
|
vet_clinic_address = $10,
|
||||||
vet_doctor_name = $11,
|
vet_account_number = $11,
|
||||||
gender = $12,
|
vet_doctor_name = $12,
|
||||||
date_of_birth = $13,
|
gender = $13,
|
||||||
gotcha_day = $14,
|
date_of_birth = $14,
|
||||||
chart_color = $15,
|
gotcha_day = $15,
|
||||||
photo_data_url = $16,
|
chart_color = $16,
|
||||||
photo_object_key = $17,
|
photo_data_url = $17,
|
||||||
photo_content_type = $18,
|
photo_object_key = $18,
|
||||||
photo_updated_at = $19,
|
photo_content_type = $19,
|
||||||
notify_on_dob = $20,
|
photo_updated_at = $20,
|
||||||
notify_on_gotcha_day = $21,
|
notify_on_dob = $21,
|
||||||
public_profile_code = $22,
|
notify_on_gotcha_day = $22,
|
||||||
public_profile_enabled = $23
|
public_profile_code = $23,
|
||||||
|
public_profile_enabled = $24,
|
||||||
|
location_details = $25
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND workspace_id = $24
|
AND workspace_id = $26
|
||||||
AND memorialized_at IS NULL
|
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
|
SELECT weight_grams::text
|
||||||
FROM weight_records
|
FROM weight_records
|
||||||
@@ -545,6 +685,7 @@ export const updateBird = async ({
|
|||||||
motivators,
|
motivators,
|
||||||
demotivators,
|
demotivators,
|
||||||
favoriteSnack,
|
favoriteSnack,
|
||||||
|
locationLabel,
|
||||||
vetClinicName,
|
vetClinicName,
|
||||||
vetClinicAddress,
|
vetClinicAddress,
|
||||||
vetAccountNumber,
|
vetAccountNumber,
|
||||||
@@ -561,6 +702,7 @@ export const updateBird = async ({
|
|||||||
notifyOnGotchaDay,
|
notifyOnGotchaDay,
|
||||||
publicProfileCode,
|
publicProfileCode,
|
||||||
publicProfileEnabled,
|
publicProfileEnabled,
|
||||||
|
locationDetails ?? null,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -590,7 +732,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, 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
|
SELECT weight_grams::text
|
||||||
FROM weight_records
|
FROM weight_records
|
||||||
@@ -626,7 +768,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, 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
|
SELECT weight_grams::text
|
||||||
FROM weight_records
|
FROM weight_records
|
||||||
@@ -666,7 +808,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, 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
|
SELECT weight_grams::text
|
||||||
FROM weight_records
|
FROM weight_records
|
||||||
@@ -762,6 +904,17 @@ 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;
|
||||||
@@ -809,22 +962,6 @@ 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 & {
|
||||||
|
|||||||
@@ -531,6 +531,10 @@ test('getPlatformAdminSummary counts memorialized birds separately', async () =>
|
|||||||
rescue_birds: 5,
|
rescue_birds: 5,
|
||||||
pending_rescues: 1,
|
pending_rescues: 1,
|
||||||
daily_users: 2,
|
daily_users: 2,
|
||||||
|
household_basic_subscriptions: 2,
|
||||||
|
household_plus_subscriptions: 1,
|
||||||
|
household_macaw_subscriptions: 0,
|
||||||
|
household_hyacinth_macaw_subscriptions: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -543,4 +547,7 @@ test('getPlatformAdminSummary counts memorialized birds separately', async () =>
|
|||||||
assert.match(calls[0].text, /memorialized_at IS NOT NULL/);
|
assert.match(calls[0].text, /memorialized_at IS NOT NULL/);
|
||||||
assert.match(calls[0].text, /rescue_birds/);
|
assert.match(calls[0].text, /rescue_birds/);
|
||||||
assert.match(calls[0].text, /workspaces\.workspace_type = 'rescue'/);
|
assert.match(calls[0].text, /workspaces\.workspace_type = 'rescue'/);
|
||||||
|
assert.equal(summary?.household_basic_subscriptions, 2);
|
||||||
|
assert.match(calls[0].text, /billing_plan = 'household_basic'/);
|
||||||
|
assert.match(calls[0].text, /subscription_status IN \('active', 'trialing'\)/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -605,6 +605,10 @@ export const getPlatformAdminSummary = async () => {
|
|||||||
rescue_birds: number;
|
rescue_birds: number;
|
||||||
pending_rescues: number;
|
pending_rescues: number;
|
||||||
daily_users: number;
|
daily_users: number;
|
||||||
|
household_basic_subscriptions: number;
|
||||||
|
household_plus_subscriptions: number;
|
||||||
|
household_macaw_subscriptions: number;
|
||||||
|
household_hyacinth_macaw_subscriptions: number;
|
||||||
}>(
|
}>(
|
||||||
`SELECT
|
`SELECT
|
||||||
(SELECT COUNT(*)::int FROM birds) AS total_birds,
|
(SELECT COUNT(*)::int FROM birds) AS total_birds,
|
||||||
@@ -614,7 +618,11 @@ export const getPlatformAdminSummary = async () => {
|
|||||||
(SELECT COUNT(*)::int FROM workspaces WHERE workspace_type = 'rescue') AS rescue_workspaces,
|
(SELECT COUNT(*)::int FROM workspaces WHERE workspace_type = 'rescue') AS rescue_workspaces,
|
||||||
(SELECT COUNT(*)::int FROM birds INNER JOIN workspaces ON workspaces.id = birds.workspace_id WHERE workspaces.workspace_type = 'rescue') AS rescue_birds,
|
(SELECT COUNT(*)::int FROM birds INNER JOIN workspaces ON workspaces.id = birds.workspace_id WHERE workspaces.workspace_type = 'rescue') AS rescue_birds,
|
||||||
(SELECT COUNT(*)::int FROM workspaces WHERE workspace_type = 'rescue' AND rescue_verification_status = 'pending') AS pending_rescues,
|
(SELECT COUNT(*)::int FROM workspaces WHERE workspace_type = 'rescue' AND rescue_verification_status = 'pending') AS pending_rescues,
|
||||||
(SELECT COUNT(DISTINCT user_id)::int FROM auth_sessions WHERE created_at >= CURRENT_DATE) AS daily_users`,
|
(SELECT COUNT(DISTINCT user_id)::int FROM auth_sessions WHERE created_at >= CURRENT_DATE) AS daily_users,
|
||||||
|
(SELECT COUNT(*)::int FROM workspaces WHERE billing_plan = 'household_basic' AND subscription_status IN ('active', 'trialing')) AS household_basic_subscriptions,
|
||||||
|
(SELECT COUNT(*)::int FROM workspaces WHERE billing_plan = 'household_plus' AND subscription_status IN ('active', 'trialing')) AS household_plus_subscriptions,
|
||||||
|
(SELECT COUNT(*)::int FROM workspaces WHERE billing_plan = 'household_macaw' AND subscription_status IN ('active', 'trialing')) AS household_macaw_subscriptions,
|
||||||
|
(SELECT COUNT(*)::int FROM workspaces WHERE billing_plan = 'household_hyacinth_macaw' AND subscription_status IN ('active', 'trialing')) AS household_hyacinth_macaw_subscriptions`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return result.rows[0];
|
return result.rows[0];
|
||||||
|
|||||||
@@ -101,6 +101,8 @@ 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;
|
||||||
@@ -174,6 +176,26 @@ 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;
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ 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}
|
||||||
@@ -142,6 +143,7 @@ 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}
|
||||||
|
|||||||
+10
-1
@@ -53,6 +53,7 @@ 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}
|
||||||
@@ -94,6 +95,12 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:5000/api/health/ready').then(r => { if (!r.ok) process.exit(1) }).catch(() => process.exit(1))\""]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
ports:
|
ports:
|
||||||
- "5000:5000"
|
- "5000:5000"
|
||||||
command: >
|
command: >
|
||||||
@@ -102,6 +109,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
worker:
|
worker:
|
||||||
build:
|
build:
|
||||||
@@ -129,6 +137,7 @@ 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}
|
||||||
@@ -160,7 +169,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:-http://localhost:5000/api}
|
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-/api}
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ RUN npm ci
|
|||||||
COPY tsconfig*.json ./
|
COPY tsconfig*.json ./
|
||||||
COPY vite.config.ts ./
|
COPY vite.config.ts ./
|
||||||
COPY index.html ./
|
COPY index.html ./
|
||||||
|
COPY public ./public
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ RUN npm install
|
|||||||
COPY tsconfig*.json ./
|
COPY tsconfig*.json ./
|
||||||
COPY vite.config.ts ./
|
COPY vite.config.ts ./
|
||||||
COPY index.html ./
|
COPY index.html ./
|
||||||
|
COPY public ./public
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["npm", "run", "dev", "--", "--host"]
|
CMD ["npm", "run", "dev", "--", "--host"]
|
||||||
|
|||||||
+970
-653
File diff suppressed because it is too large
Load Diff
+797
-11
@@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
--ink: #1f2a2a;
|
--ink: #1f2a2a;
|
||||||
--muted: #5d5f59;
|
--muted: #5d5f59;
|
||||||
@@ -40,6 +41,7 @@ body,
|
|||||||
#root {
|
#root {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -47,6 +49,8 @@ body {
|
|||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
position: relative;
|
position: relative;
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
|
overflow-x: hidden;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
body::before {
|
body::before {
|
||||||
@@ -120,6 +124,7 @@ textarea {
|
|||||||
.content-shell {
|
.content-shell {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-alert-notification {
|
.top-alert-notification {
|
||||||
@@ -192,11 +197,13 @@ textarea {
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
align-self: start;
|
align-self: start;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-nav {
|
.side-nav {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-lockup {
|
.brand-lockup {
|
||||||
@@ -452,6 +459,7 @@ textarea {
|
|||||||
.stack-grid {
|
.stack-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-card,
|
.hero-card,
|
||||||
@@ -535,6 +543,7 @@ textarea {
|
|||||||
.forms-grid {
|
.forms-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-stats {
|
.hero-stats {
|
||||||
@@ -616,14 +625,6 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -728,6 +729,7 @@ textarea {
|
|||||||
border-radius: 28px;
|
border-radius: 28px;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
align-self: start;
|
align-self: start;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-header {
|
.panel-header {
|
||||||
@@ -736,6 +738,7 @@ textarea {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pulse-panel .panel-header {
|
.pulse-panel .panel-header {
|
||||||
@@ -754,6 +757,7 @@ textarea {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-header-actions {
|
.member-header-actions {
|
||||||
@@ -854,6 +858,7 @@ textarea {
|
|||||||
.bird-list {
|
.bird-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.9rem;
|
gap: 0.9rem;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bird-card-header {
|
.bird-card-header {
|
||||||
@@ -1113,6 +1118,11 @@ textarea {
|
|||||||
var(--card-bg);
|
var(--card-bg);
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card .weight-chart {
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overview-chart-card::before {
|
.overview-chart-card::before {
|
||||||
@@ -1215,6 +1225,264 @@ 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: 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,
|
.legend-grid,
|
||||||
.detail-grid,
|
.detail-grid,
|
||||||
.summary-grid {
|
.summary-grid {
|
||||||
@@ -1314,6 +1582,7 @@ 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;
|
||||||
@@ -1338,11 +1607,12 @@ textarea {
|
|||||||
.bird-detail-tab-panel {
|
.bird-detail-tab-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-copy {
|
.profile-copy {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.3rem;
|
gap: 0.18rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-copy h3 {
|
.profile-copy h3 {
|
||||||
@@ -1350,6 +1620,10 @@ 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;
|
||||||
@@ -1701,6 +1975,7 @@ label {
|
|||||||
|
|
||||||
.bulk-weight-table-shell {
|
.bulk-weight-table-shell {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
border: 1px solid rgba(53, 129, 98, 0.18);
|
border: 1px solid rgba(53, 129, 98, 0.18);
|
||||||
background: rgba(255, 252, 246, 0.72);
|
background: rgba(255, 252, 246, 0.72);
|
||||||
@@ -1798,6 +2073,60 @@ 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;
|
||||||
@@ -2127,7 +2456,8 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2204,17 +2534,25 @@ label {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page-tabs {
|
.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;
|
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 {
|
||||||
@@ -2246,3 +2584,451 @@ label {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
:root {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 18% 8%, rgba(222, 124, 58, 0.18), transparent 24%),
|
||||||
|
radial-gradient(circle at 82% 10%, rgba(53, 136, 110, 0.18), transparent 24%),
|
||||||
|
linear-gradient(180deg, #fef5e7 0%, #ece3c8 48%, #dceee3 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
opacity: 0.22;
|
||||||
|
background-size: auto 1200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
min-height: 46px;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 0.78rem 0.85rem;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell,
|
||||||
|
.auth-shell {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-shell,
|
||||||
|
.stack-grid,
|
||||||
|
.dashboard-grid,
|
||||||
|
.forms-grid,
|
||||||
|
.settings-grid,
|
||||||
|
.settings-column,
|
||||||
|
.flock-detail-column,
|
||||||
|
.flock-member-panel,
|
||||||
|
.flock-member-sections {
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card,
|
||||||
|
.panel {
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 1rem;
|
||||||
|
box-shadow: 0 14px 26px rgba(89, 48, 42, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card {
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card h1 {
|
||||||
|
max-width: none;
|
||||||
|
font-size: 2.15rem;
|
||||||
|
line-height: 1.04;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header .button-row,
|
||||||
|
.member-header-actions,
|
||||||
|
.overview-alert-actions {
|
||||||
|
justify-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button,
|
||||||
|
.secondary-button,
|
||||||
|
.danger-button,
|
||||||
|
.range-alert-button {
|
||||||
|
min-height: 44px;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 0.72rem 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row > button,
|
||||||
|
.button-row > a,
|
||||||
|
.top-alert-actions > button {
|
||||||
|
flex: 1 1 9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-rail {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 25;
|
||||||
|
margin: -0.75rem -0.75rem 0;
|
||||||
|
padding: 0.6rem 0.75rem 0.7rem;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.55rem;
|
||||||
|
background: linear-gradient(180deg, rgba(254, 245, 231, 0.98), rgba(254, 245, 231, 0.88));
|
||||||
|
border-bottom: 1px solid rgba(53, 129, 98, 0.16);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-lockup {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-nav.panel {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
backdrop-filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-tabs {
|
||||||
|
margin: 0 -0.2rem;
|
||||||
|
padding: 0.1rem 0.2rem 0.25rem;
|
||||||
|
grid-auto-columns: minmax(5.4rem, 1fr);
|
||||||
|
gap: 0.35rem;
|
||||||
|
scroll-snap-type: x proximity;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-tab {
|
||||||
|
min-height: 40px;
|
||||||
|
min-width: 5.4rem;
|
||||||
|
padding: 0.5rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
scroll-snap-align: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-nav .secondary-button {
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-switcher {
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-switcher-list {
|
||||||
|
margin: 0 -0.2rem;
|
||||||
|
padding: 0 0.2rem 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-switcher-item {
|
||||||
|
min-width: min(180px, 78vw);
|
||||||
|
padding: 0.5rem 0.65rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-alert-notification {
|
||||||
|
gap: 0.65rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card {
|
||||||
|
padding: 0.75rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card .weight-chart {
|
||||||
|
min-width: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-chart-card {
|
||||||
|
min-height: 230px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-footer {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-grid,
|
||||||
|
.detail-grid,
|
||||||
|
.summary-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(150px, 100%), 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-list-panel {
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flock-detail-column {
|
||||||
|
order: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-list {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-card {
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-avatar {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-detail-panel {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-detail-tabs {
|
||||||
|
position: sticky;
|
||||||
|
top: 4.35rem;
|
||||||
|
z-index: 15;
|
||||||
|
margin: -0.2rem -0.35rem 0.65rem;
|
||||||
|
padding: 0.2rem 0.35rem 0.35rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
overflow-x: auto;
|
||||||
|
gap: 0.35rem;
|
||||||
|
background: linear-gradient(180deg, rgba(250, 244, 232, 0.96), rgba(250, 244, 232, 0.84));
|
||||||
|
border-radius: 16px;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
transform: none;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-detail-tab {
|
||||||
|
width: 42px;
|
||||||
|
height: 40px;
|
||||||
|
min-width: 42px;
|
||||||
|
border-left: 1px solid rgba(39, 105, 179, 0.14);
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-detail-tab.active {
|
||||||
|
box-shadow: 0 8px 16px rgba(39, 105, 179, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-hero {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
padding: 0.85rem;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-actions {
|
||||||
|
position: static;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
justify-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-photo {
|
||||||
|
width: 86px;
|
||||||
|
height: 86px;
|
||||||
|
border-radius: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-copy h3 {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-form,
|
||||||
|
.settings-nested-grid,
|
||||||
|
.education-admin-basics,
|
||||||
|
.quiz-editor-options,
|
||||||
|
.dose-schedule-row,
|
||||||
|
.verified-location-search-row,
|
||||||
|
.verified-location-search-row.has-selected-location,
|
||||||
|
.medication-dose-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verified-location-search-row button,
|
||||||
|
.verified-location-search-row.has-selected-location button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-field {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-row {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented-control {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(8rem, 100%), 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented-option {
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 0.72rem 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-editor {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
justify-items: start;
|
||||||
|
padding: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-preview-card {
|
||||||
|
align-items: start;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-weight-table {
|
||||||
|
min-width: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-weight-table th,
|
||||||
|
.bulk-weight-table td {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-modal-backdrop {
|
||||||
|
padding: 0.75rem;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-modal {
|
||||||
|
max-height: min(88vh, 760px);
|
||||||
|
border-radius: 22px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 430px) {
|
||||||
|
.app-shell,
|
||||||
|
.auth-shell {
|
||||||
|
padding: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-rail {
|
||||||
|
margin: -0.55rem -0.55rem 0;
|
||||||
|
padding: 0.5rem 0.55rem 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-shell,
|
||||||
|
.stack-grid,
|
||||||
|
.dashboard-grid,
|
||||||
|
.forms-grid,
|
||||||
|
.settings-grid,
|
||||||
|
.settings-column,
|
||||||
|
.flock-detail-column,
|
||||||
|
.flock-member-panel,
|
||||||
|
.flock-member-sections {
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card,
|
||||||
|
.panel {
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card h1 {
|
||||||
|
font-size: 1.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-tabs {
|
||||||
|
grid-auto-columns: minmax(4.8rem, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-tab {
|
||||||
|
min-width: 4.8rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-nav .secondary-button {
|
||||||
|
padding-inline: 0.58rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-alert-notification {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row > button,
|
||||||
|
.button-row > a,
|
||||||
|
.top-alert-actions > button,
|
||||||
|
.settings-save-row > button {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-card-header {
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bird-card-title {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-hero {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-photo {
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-icon-button {
|
||||||
|
flex: 1 1 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card {
|
||||||
|
margin-inline: -0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nested-card,
|
||||||
|
.inset-panel,
|
||||||
|
.legend-card,
|
||||||
|
.detail-card,
|
||||||
|
.summary-card,
|
||||||
|
.weight-reference-card,
|
||||||
|
.vet-visit-card,
|
||||||
|
.toggle-card {
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-modal-backdrop {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,5 +5,11 @@ export default defineConfig({
|
|||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://backend:5000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user