Merge branch 'dev'
# Conflicts: # backend/src/app.ts # backend/src/db/schema.ts # backend/src/queues/adoptionReportQueue.ts # backend/src/reports/adoptionReport.ts # backend/src/reports/adoptionReportJob.ts # backend/src/repositories/birdRepository.ts # backend/src/types.ts # frontend/src/App.tsx # frontend/src/index.css
This commit is contained in:
+482
-197
@@ -12,9 +12,8 @@ import nodemailer, { type SendMailOptions } from 'nodemailer';
|
||||
import Stripe from 'stripe';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { db } from './db/client.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 { enqueueMedicationReminderJob, getMedicationReminderQueueCounts } from './queues/medicationReminderQueue.js';
|
||||
import {
|
||||
@@ -38,6 +37,7 @@ import {
|
||||
completePendingBirdTransfersForOwner,
|
||||
createBird,
|
||||
createBirdMilestoneReminderDelivery,
|
||||
createBirdTimelineEvent,
|
||||
createMedicationReminderDelivery,
|
||||
createBirdTransferCode,
|
||||
createMedicationForBird,
|
||||
@@ -51,8 +51,8 @@ import {
|
||||
getBirdById,
|
||||
getBirdByPublicProfileCode,
|
||||
getOpenBirdTransferCode,
|
||||
getOpenBirdTransferCodeForBird,
|
||||
listBirds,
|
||||
listBirdTimelineEvents,
|
||||
listDueBirdMilestoneReminders,
|
||||
listDueMedicationReminders,
|
||||
listMemorializedBirds,
|
||||
@@ -98,7 +98,6 @@ import {
|
||||
getMembershipForUser,
|
||||
getNextWorkspaceId,
|
||||
getWorkspaceById,
|
||||
getWorkspaceBirdCount,
|
||||
getWorkspaceTotalBirdCount,
|
||||
listOwnedWorkspacesByOwnerEmail,
|
||||
listRescueWorkspacesForAdmin,
|
||||
@@ -114,14 +113,16 @@ import {
|
||||
upsertWorkspaceMember,
|
||||
} from './repositories/workspaceRepository.js';
|
||||
import type {
|
||||
AuthContext,
|
||||
AuditLogEntryRow,
|
||||
BillingInterval,
|
||||
BillingPlan,
|
||||
BirdGender,
|
||||
AuthContext,
|
||||
AuditLogEntryRow,
|
||||
BillingInterval,
|
||||
BillingPlan,
|
||||
BirdGender,
|
||||
BirdMilestoneReminderCandidateRow,
|
||||
BirdRow,
|
||||
FlockNoteRow,
|
||||
BirdRow,
|
||||
BirdTimelineEventType,
|
||||
BirdTimelineEventRow,
|
||||
FlockNoteRow,
|
||||
IntegrationTokenRow,
|
||||
LostBirdMatchRow,
|
||||
MedicationAdministrationRow,
|
||||
@@ -270,6 +271,26 @@ const birdProfileListSchema = z
|
||||
.optional()
|
||||
.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({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
tagId: z.string().trim().max(80).optional().or(z.literal('')),
|
||||
@@ -277,11 +298,14 @@ const birdSchema = z.object({
|
||||
motivators: birdProfileListSchema,
|
||||
demotivators: birdProfileListSchema,
|
||||
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('')),
|
||||
vetClinicAddress: z.string().trim().max(500).optional().or(z.literal('')),
|
||||
vetAccountNumber: z.string().trim().max(120).optional().or(z.literal('')),
|
||||
vetDoctorName: z.string().trim().max(160).optional().or(z.literal('')),
|
||||
gender: birdGenderSchema.optional(),
|
||||
hatchDay: dateStringSchema.optional().or(z.literal('')),
|
||||
dateOfBirth: dateStringSchema.optional().or(z.literal('')),
|
||||
gotchaDay: dateStringSchema.optional().or(z.literal('')),
|
||||
chartColor: chartColorSchema.optional(),
|
||||
@@ -301,6 +325,146 @@ const memorialReminderPreferenceSchema = z.object({
|
||||
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({
|
||||
weightGrams: z.coerce.number().positive().max(10000),
|
||||
recordedOn: dateStringSchema,
|
||||
@@ -637,11 +801,14 @@ const normalizeBird = (row: BirdRow) => ({
|
||||
motivators: row.motivators,
|
||||
demotivators: row.demotivators,
|
||||
favoriteSnack: row.favorite_snack,
|
||||
locationLabel: row.location_label,
|
||||
locationDetails: row.location_details,
|
||||
vetClinicName: row.vet_clinic_name,
|
||||
vetClinicAddress: row.vet_clinic_address,
|
||||
vetAccountNumber: row.vet_account_number,
|
||||
vetDoctorName: row.vet_doctor_name,
|
||||
gender: row.gender,
|
||||
hatchDay: row.date_of_birth,
|
||||
dateOfBirth: row.date_of_birth,
|
||||
gotchaDay: row.gotcha_day,
|
||||
chartColor: row.chart_color,
|
||||
@@ -664,39 +831,13 @@ const normalizeBird = (row: BirdRow) => ({
|
||||
|
||||
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) => ({
|
||||
id: row.id,
|
||||
workspaceId: row.workspace_id,
|
||||
name: row.name,
|
||||
favoriteSnack: row.favorite_snack,
|
||||
gender: row.gender,
|
||||
hatchDay: row.date_of_birth,
|
||||
dateOfBirth: row.date_of_birth,
|
||||
photoDataUrl: getBirdPhotoUrl(row),
|
||||
});
|
||||
@@ -771,6 +912,24 @@ const normalizeAuditLogEntry = (row: AuditLogEntryRow) => ({
|
||||
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) => ({
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
@@ -847,6 +1006,13 @@ const lostBirdReportLimiter = rateLimit({
|
||||
legacyHeaders: false,
|
||||
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) => {
|
||||
if (!stripeWebhookSecret) {
|
||||
res.status(503).json({ error: 'Stripe webhook is not configured.' });
|
||||
@@ -993,26 +1159,6 @@ const subscriptionAllowsWrite = (workspace: WorkspaceRow) => {
|
||||
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 => {
|
||||
if (status === 'active' || status === 'trialing' || status === 'past_due' || status === 'canceled' || status === 'unpaid') {
|
||||
return status;
|
||||
@@ -1403,6 +1549,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 defaultPhotoPath = path.join(process.cwd(), 'assets', 'yoda-default.png');
|
||||
|
||||
@@ -2267,59 +2444,6 @@ const ensureBirdWritable = (bird: BirdRow, res: Response) => {
|
||||
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 (
|
||||
auth: AuthContext,
|
||||
action: string,
|
||||
@@ -2343,51 +2467,51 @@ 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 = (
|
||||
workspace: WorkspaceRow,
|
||||
payload: z.infer<typeof workspaceSchema>,
|
||||
) => workspace.workspace_type === 'standard' && payload.workspaceType === 'standard' && payload.name === workspace.name;
|
||||
|
||||
app.get('/api/health/live', (_req: Request, res: Response) => {
|
||||
res.json({
|
||||
ok: true,
|
||||
service: 'flockpal-backend',
|
||||
status: 'live',
|
||||
uptimeSeconds: Math.round(process.uptime()),
|
||||
checkedAt: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/health/ready', 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/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/health', (_req: Request, res: Response) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get('/api/metrics', requireAuth, requireAdmin, async (_req: Request, res: Response, next: NextFunction) => {
|
||||
@@ -2419,7 +2543,6 @@ app.get('/api/metrics', requireAuth, requireAdmin, async (_req: Request, res: Re
|
||||
queues: {
|
||||
birdMilestoneReminders: birdMilestoneReminderQueueCounts,
|
||||
medicationReminders: medicationReminderQueueCounts,
|
||||
adoptionReports: await getAdoptionReportQueueCounts(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -2814,6 +2937,12 @@ app.get('/api/admin/summary', requireAuth, requireSessionAuth, requireAdmin, asy
|
||||
rescueBirds: Number(summary?.rescue_birds ?? 0),
|
||||
pendingRescues: Number(summary?.pending_rescues ?? 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) {
|
||||
@@ -3433,6 +3562,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) => {
|
||||
try {
|
||||
const [birds, memorializedBirds] = await Promise.all([
|
||||
@@ -3445,6 +3629,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) => {
|
||||
try {
|
||||
const token = typeof req.query.token === 'string' ? req.query.token : '';
|
||||
@@ -3516,29 +3765,13 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
|
||||
let uploadedObjectKeyToCleanup: string | null = null;
|
||||
|
||||
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 photoStorage = await resolveBirdPhotoStorage({
|
||||
birdId,
|
||||
workspaceId: req.auth!.workspace.id,
|
||||
photoDataUrl: emptyToNull(parsed.data.photoDataUrl),
|
||||
});
|
||||
const locationDetails = normalizeVerifiedLocationDetails(parsed.data.locationDetails);
|
||||
uploadedObjectKeyToCleanup = photoStorage.photoObjectKey;
|
||||
const bird = await createBird({
|
||||
birdId,
|
||||
@@ -3549,12 +3782,14 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
|
||||
motivators: emptyToNull(parsed.data.motivators),
|
||||
demotivators: emptyToNull(parsed.data.demotivators),
|
||||
favoriteSnack: emptyToNull(parsed.data.favoriteSnack),
|
||||
locationLabel: formatVerifiedLocationLabel(locationDetails) ?? emptyToNull(parsed.data.locationLabel),
|
||||
locationDetails,
|
||||
vetClinicName: emptyToNull(parsed.data.vetClinicName),
|
||||
vetClinicAddress: emptyToNull(parsed.data.vetClinicAddress),
|
||||
vetAccountNumber: emptyToNull(parsed.data.vetAccountNumber),
|
||||
vetDoctorName: emptyToNull(parsed.data.vetDoctorName),
|
||||
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),
|
||||
chartColor: parsed.data.chartColor ?? '#cb3a35',
|
||||
photoDataUrl: photoStorage.photoDataUrl,
|
||||
@@ -3572,6 +3807,14 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
|
||||
species: bird!.species,
|
||||
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!) });
|
||||
} catch (error) {
|
||||
await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup);
|
||||
@@ -3656,6 +3899,13 @@ app.post('/api/birds/:birdId/transfer', requireAuth, requireWriteAccess, require
|
||||
destinationOwnerEmail,
|
||||
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) });
|
||||
} catch (error) {
|
||||
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
|
||||
@@ -3686,7 +3936,25 @@ app.post(
|
||||
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) {
|
||||
throw new Error('Unable to create bird transfer code.');
|
||||
@@ -3708,30 +3976,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(
|
||||
'/api/bird-transfer-codes/:code/accept',
|
||||
requireAuth,
|
||||
@@ -3772,6 +4016,13 @@ app.post(
|
||||
sourceWorkspaceName: transferCode.workspace_name,
|
||||
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) });
|
||||
} catch (error) {
|
||||
@@ -3804,7 +4055,25 @@ app.post(
|
||||
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) {
|
||||
throw new Error('Unable to create bird transfer code.');
|
||||
@@ -3872,6 +4141,7 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
|
||||
});
|
||||
uploadedObjectKeyToCleanup =
|
||||
photoStorage.photoObjectKey && photoStorage.photoObjectKey !== existingBird.photo_object_key ? photoStorage.photoObjectKey : null;
|
||||
const locationDetails = normalizeVerifiedLocationDetails(parsed.data.locationDetails);
|
||||
const bird = await updateBird({
|
||||
birdId: req.params.birdId,
|
||||
workspaceId: req.auth!.workspace.id,
|
||||
@@ -3881,12 +4151,14 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
|
||||
motivators: emptyToNull(parsed.data.motivators),
|
||||
demotivators: emptyToNull(parsed.data.demotivators),
|
||||
favoriteSnack: emptyToNull(parsed.data.favoriteSnack),
|
||||
locationLabel: formatVerifiedLocationLabel(locationDetails) ?? emptyToNull(parsed.data.locationLabel),
|
||||
locationDetails,
|
||||
vetClinicName: emptyToNull(parsed.data.vetClinicName),
|
||||
vetClinicAddress: emptyToNull(parsed.data.vetClinicAddress),
|
||||
vetAccountNumber: emptyToNull(parsed.data.vetAccountNumber),
|
||||
vetDoctorName: emptyToNull(parsed.data.vetDoctorName),
|
||||
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),
|
||||
chartColor: parsed.data.chartColor ?? '#cb3a35',
|
||||
photoDataUrl: photoStorage.photoDataUrl,
|
||||
@@ -3910,6 +4182,19 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
|
||||
previousName: existingBird.name,
|
||||
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) });
|
||||
} catch (error) {
|
||||
await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup);
|
||||
|
||||
@@ -215,6 +215,8 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
||||
motivators VARCHAR(1000),
|
||||
demotivators VARCHAR(1000),
|
||||
favorite_snack VARCHAR(160),
|
||||
location_label VARCHAR(160),
|
||||
location_details JSONB,
|
||||
vet_clinic_name VARCHAR(160),
|
||||
vet_clinic_address VARCHAR(500),
|
||||
vet_account_number VARCHAR(120),
|
||||
@@ -243,6 +245,8 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
||||
ADD COLUMN IF NOT EXISTS motivators VARCHAR(1000),
|
||||
ADD COLUMN IF NOT EXISTS demotivators VARCHAR(1000),
|
||||
ADD COLUMN IF NOT EXISTS favorite_snack VARCHAR(160),
|
||||
ADD COLUMN IF NOT EXISTS location_label VARCHAR(160),
|
||||
ADD COLUMN IF NOT EXISTS location_details JSONB,
|
||||
ADD COLUMN IF NOT EXISTS vet_clinic_name VARCHAR(160),
|
||||
ADD COLUMN IF NOT EXISTS vet_clinic_address VARCHAR(500),
|
||||
ADD COLUMN IF NOT EXISTS vet_account_number VARCHAR(120),
|
||||
@@ -342,9 +346,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
||||
ON pending_bird_transfers (LOWER(destination_owner_email), created_at DESC)
|
||||
WHERE completed_at IS NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_bird_transfers_open_bird
|
||||
ON pending_bird_transfers (bird_id)
|
||||
WHERE completed_at IS NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_bird_transfers_open_bird
|
||||
ON pending_bird_transfers (bird_id)
|
||||
WHERE completed_at IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bird_transfer_codes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -368,9 +372,35 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
||||
WHERE completed_at IS NULL
|
||||
AND revoked_at IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flock_notes (
|
||||
CREATE TABLE IF NOT EXISTS bird_timeline_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
||||
event_type VARCHAR(40) NOT NULL,
|
||||
from_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL,
|
||||
to_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL,
|
||||
from_workspace_name VARCHAR(160),
|
||||
to_workspace_name VARCHAR(160),
|
||||
from_owner_email VARCHAR(255),
|
||||
to_owner_email VARCHAR(255),
|
||||
location_label VARCHAR(160),
|
||||
location_details JSONB,
|
||||
note TEXT,
|
||||
event_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
ALTER TABLE bird_timeline_events
|
||||
ADD COLUMN IF NOT EXISTS note TEXT,
|
||||
ADD COLUMN IF NOT EXISTS location_details JSONB,
|
||||
ADD COLUMN IF NOT EXISTS event_date DATE NOT NULL DEFAULT CURRENT_DATE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_bird_timeline_events_bird_created
|
||||
ON bird_timeline_events (bird_id, event_date DESC, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flock_notes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
bird_id UUID REFERENCES birds(id) ON DELETE SET NULL,
|
||||
title VARCHAR(160) NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
|
||||
@@ -40,5 +40,3 @@ export const closeAdoptionReportQueue = async () => {
|
||||
await adoptionReportQueue.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;
|
||||
};
|
||||
|
||||
const measureFactHeight = (doc: PDFKit.PDFDocument, value: string, width: number, minHeight = 43) => {
|
||||
doc.font('Helvetica-Bold').fontSize(10);
|
||||
const textHeight = doc.heightOfString(value, {
|
||||
width: width - 16,
|
||||
lineGap: 1,
|
||||
});
|
||||
return Math.max(minHeight, 27 + Math.min(textHeight, 38));
|
||||
};
|
||||
|
||||
const drawFact = (doc: PDFKit.PDFDocument, label: string, value: string, x: number, y: number, width: number, height?: number) => {
|
||||
const cardHeight = height ?? measureFactHeight(doc, value, width);
|
||||
doc.roundedRect(x, y, width, cardHeight, 6).fillAndStroke(colors.panel, colors.border);
|
||||
const drawFact = (doc: PDFKit.PDFDocument, label: string, value: string, x: number, y: number, width: number) => {
|
||||
doc.roundedRect(x, y, width, 43, 6).fillAndStroke(colors.panel, colors.border);
|
||||
doc.fillColor(colors.muted).fontSize(7).font('Helvetica-Bold').text(label.toUpperCase(), x + 8, y + 8, { width: width - 16 });
|
||||
doc.fillColor(colors.ink).fontSize(10).font('Helvetica-Bold').text(value, x + 8, y + 21, {
|
||||
width: width - 16,
|
||||
height: cardHeight - 27,
|
||||
lineGap: 1,
|
||||
ellipsis: true,
|
||||
});
|
||||
return cardHeight;
|
||||
doc.fillColor(colors.ink).fontSize(10).font('Helvetica-Bold').text(value, x + 8, y + 21, { width: width - 16, ellipsis: true });
|
||||
};
|
||||
|
||||
const drawTextCard = (doc: PDFKit.PDFDocument, label: string, value: string, x: number, y: number, width: number, height = 58) => {
|
||||
@@ -160,12 +144,8 @@ const drawSimpleWeightChart = (doc: PDFKit.PDFDocument, weights: WeightRow[], bi
|
||||
}
|
||||
|
||||
const latestDate = new Date(`${plottedWeights[plottedWeights.length - 1].recorded_on.slice(0, 10)}T00:00:00Z`);
|
||||
const earliestDate = new Date(`${plottedWeights[0].recorded_on.slice(0, 10)}T00:00:00Z`);
|
||||
const startDate = new Date(latestDate);
|
||||
startDate.setUTCDate(startDate.getUTCDate() - 13);
|
||||
if (earliestDate > startDate) {
|
||||
startDate.setTime(earliestDate.getTime());
|
||||
}
|
||||
startDate.setUTCDate(startDate.getUTCDate() - 29);
|
||||
const visibleWeights = plottedWeights.filter((entry) => {
|
||||
const recordedOn = new Date(`${entry.recorded_on.slice(0, 10)}T00:00:00Z`);
|
||||
return recordedOn >= startDate && recordedOn <= latestDate;
|
||||
@@ -377,14 +357,16 @@ export const renderAdoptionReportPdf = async ({
|
||||
y = page.margin;
|
||||
}
|
||||
y = drawSectionTitle(doc, 'Veterinary Clinic Info', y);
|
||||
drawFact(doc, 'Clinic name', bird.vet_clinic_name || 'Not recorded', page.margin, y, factWidth);
|
||||
drawFact(doc, 'Account #', bird.vet_account_number || 'Not recorded', page.margin + factWidth + factGap, y, factWidth);
|
||||
y += 50;
|
||||
const clinicAddressHeight = measureFactHeight(doc, bird.vet_clinic_address || 'Not recorded', contentWidth, 58);
|
||||
drawFact(doc, 'Clinic address', bird.vet_clinic_address || 'Not recorded', page.margin, y, contentWidth, clinicAddressHeight);
|
||||
y += clinicAddressHeight + 7;
|
||||
drawFact(doc, 'Dr. name', bird.vet_doctor_name || 'Not recorded', page.margin, y, factWidth);
|
||||
y += 50;
|
||||
const vetFacts = [
|
||||
['Clinic name', bird.vet_clinic_name || 'Not recorded'],
|
||||
['Clinic address', bird.vet_clinic_address || 'Not recorded'],
|
||||
['Account #', bird.vet_account_number || 'Not recorded'],
|
||||
['Dr. name', bird.vet_doctor_name || 'Not recorded'],
|
||||
];
|
||||
vetFacts.forEach(([label, value], index) => {
|
||||
drawFact(doc, label, value, page.margin + (index % 2) * (factWidth + factGap), y + Math.floor(index / 2) * 50, factWidth);
|
||||
});
|
||||
y += Math.ceil(vetFacts.length / 2) * 50 + 8;
|
||||
|
||||
y = drawSectionTitle(doc, 'Vet Visit History', y);
|
||||
y = drawTable(
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import path from 'path';
|
||||
import sharp from 'sharp';
|
||||
|
||||
import {
|
||||
getBirdById,
|
||||
listVetVisitsForBird,
|
||||
listWeightsForBird,
|
||||
} from '../repositories/birdRepository.js';
|
||||
import { listFlockNotes } from '../repositories/auditRepository.js';
|
||||
import { getBirdById, listVetVisitsForBird, listWeightsForBird } from '../repositories/birdRepository.js';
|
||||
import { getS3ImageStorageConfig } from '../storage/imageStorageConfig.js';
|
||||
import { getSignedS3ObjectUrl } from '../storage/s3Client.js';
|
||||
import type { BirdRow } from '../types.js';
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
createPendingBirdTransfer,
|
||||
getBirdById,
|
||||
getOpenBirdTransferCode,
|
||||
getOpenBirdTransferCodeForBird,
|
||||
listWeightsForBird,
|
||||
markBirdTransferCodeCompleted,
|
||||
transferBirdToWorkspace,
|
||||
@@ -191,6 +190,46 @@ test('completePendingBirdTransfersForOwner moves pending birds and marks complet
|
||||
],
|
||||
},
|
||||
{ rowCount: 1, rows: [] },
|
||||
{
|
||||
rowCount: 1,
|
||||
rows: [
|
||||
{
|
||||
workspace_id: 10,
|
||||
workspace_name: 'Original Flock',
|
||||
owner_email: 'sender@example.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
rowCount: 1,
|
||||
rows: [
|
||||
{
|
||||
workspace_id: 22,
|
||||
workspace_name: 'Receiving Flock',
|
||||
owner_email: 'receiver@example.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
rowCount: 1,
|
||||
rows: [
|
||||
{
|
||||
id: 'timeline-1',
|
||||
bird_id: 'bird-1',
|
||||
event_type: 'transferred',
|
||||
from_workspace_id: 10,
|
||||
to_workspace_id: 22,
|
||||
from_workspace_name: 'Original Flock',
|
||||
to_workspace_name: 'Receiving Flock',
|
||||
from_owner_email: 'sender@example.com',
|
||||
to_owner_email: 'receiver@example.com',
|
||||
location_label: 'Receiving Flock',
|
||||
location_details: null,
|
||||
created_by_user_id: 'user-1',
|
||||
created_at: '2026-04-15T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
const result = await completePendingBirdTransfersForOwner('receiver@example.com', 22);
|
||||
@@ -200,6 +239,21 @@ test('completePendingBirdTransfersForOwner moves pending birds and marks complet
|
||||
assert.deepEqual(calls[1].params, ['bird-1', 10, 22]);
|
||||
assert.deepEqual(calls[2].params, ['transfer-1', 22]);
|
||||
assert.match(calls[2].text, /completed_at = CURRENT_TIMESTAMP/);
|
||||
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 () => {
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
test('getOpenBirdTransferCodeForBird ignores consumed codes', async () => {
|
||||
const { calls } = mockDb({ rowCount: 0, rows: [] });
|
||||
|
||||
const transferCode = await getOpenBirdTransferCodeForBird('bird-1', 10);
|
||||
|
||||
assert.equal(transferCode, null);
|
||||
assert.deepEqual(calls[0].params, ['bird-1', 10]);
|
||||
assert.match(calls[0].text, /completed_at IS NULL/);
|
||||
assert.match(calls[0].text, /revoked_at IS NULL/);
|
||||
});
|
||||
|
||||
test('markBirdTransferCodeCompleted consumes a code for the receiving workspace', async () => {
|
||||
const { calls } = mockDb({ rowCount: 1, rows: [] });
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import type {
|
||||
BirdMilestoneReminderDeliveryRow,
|
||||
BirdMilestoneReminderType,
|
||||
BirdRow,
|
||||
BirdTimelineEventRow,
|
||||
BirdTimelineEventType,
|
||||
BirdTransferCodeRow,
|
||||
LostBirdMatchRow,
|
||||
MedicationAdministrationRow,
|
||||
@@ -26,6 +28,8 @@ const birdSelectFields = `
|
||||
birds.motivators,
|
||||
birds.demotivators,
|
||||
birds.favorite_snack,
|
||||
birds.location_label,
|
||||
birds.location_details,
|
||||
birds.vet_clinic_name,
|
||||
birds.vet_clinic_address,
|
||||
birds.vet_account_number,
|
||||
@@ -51,6 +55,34 @@ const birdSelectFields = `
|
||||
latest.recorded_on::text AS latest_recorded_on
|
||||
`;
|
||||
|
||||
type WorkspaceTimelineSnapshot = {
|
||||
workspace_id: number;
|
||||
workspace_name: string;
|
||||
owner_email: string | null;
|
||||
};
|
||||
|
||||
const getWorkspaceTimelineSnapshot = async (workspaceId: number) => {
|
||||
const result = await db.query<WorkspaceTimelineSnapshot>(
|
||||
`SELECT
|
||||
workspaces.id AS workspace_id,
|
||||
workspaces.name AS workspace_name,
|
||||
COALESCE(workspaces.billing_email, owner_member.invite_email, owner_member.email) AS owner_email
|
||||
FROM workspaces
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT invite_email, email
|
||||
FROM workspace_members
|
||||
WHERE workspace_members.workspace_id = workspaces.id
|
||||
AND workspace_members.role = 'owner'
|
||||
ORDER BY accepted_at DESC NULLS LAST, created_at ASC
|
||||
LIMIT 1
|
||||
) owner_member ON TRUE
|
||||
WHERE workspaces.id = $1`,
|
||||
[workspaceId],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
};
|
||||
|
||||
export const getBirdById = async (birdId: string, workspaceId: number) => {
|
||||
const result = await db.query<BirdRow>(
|
||||
`SELECT
|
||||
@@ -134,6 +166,102 @@ export const listMemorializedBirds = async (workspaceId: number) => {
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
export const createBirdTimelineEvent = async ({
|
||||
birdId,
|
||||
eventType,
|
||||
fromWorkspaceId,
|
||||
toWorkspaceId,
|
||||
locationLabel,
|
||||
locationDetails,
|
||||
note,
|
||||
eventDate,
|
||||
createdByUserId,
|
||||
}: {
|
||||
birdId: string;
|
||||
eventType: BirdTimelineEventType;
|
||||
fromWorkspaceId?: number | null;
|
||||
toWorkspaceId?: number | null;
|
||||
locationLabel?: string | null;
|
||||
locationDetails?: Record<string, unknown> | null;
|
||||
note?: string | null;
|
||||
eventDate?: string | null;
|
||||
createdByUserId?: string | null;
|
||||
}) => {
|
||||
const [fromWorkspace, toWorkspace] = await Promise.all([
|
||||
fromWorkspaceId ? getWorkspaceTimelineSnapshot(fromWorkspaceId) : Promise.resolve(null),
|
||||
toWorkspaceId ? getWorkspaceTimelineSnapshot(toWorkspaceId) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
const result = await db.query<BirdTimelineEventRow>(
|
||||
`INSERT INTO bird_timeline_events (
|
||||
bird_id,
|
||||
event_type,
|
||||
from_workspace_id,
|
||||
to_workspace_id,
|
||||
from_workspace_name,
|
||||
to_workspace_name,
|
||||
from_owner_email,
|
||||
to_owner_email,
|
||||
location_label,
|
||||
note,
|
||||
event_date,
|
||||
created_by_user_id,
|
||||
location_details
|
||||
)
|
||||
VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5::varchar(160),
|
||||
$6::varchar(160),
|
||||
$7::varchar(320),
|
||||
$8::varchar(320),
|
||||
COALESCE($9::varchar(160), $6::varchar(160), $5::varchar(160)),
|
||||
$10,
|
||||
COALESCE($11::date, CURRENT_DATE),
|
||||
$12,
|
||||
$13
|
||||
)
|
||||
RETURNING id, bird_id, event_type, from_workspace_id, to_workspace_id, from_workspace_name, to_workspace_name, from_owner_email, to_owner_email, location_label, location_details, note, event_date::text, created_by_user_id, created_at`,
|
||||
[
|
||||
birdId,
|
||||
eventType,
|
||||
fromWorkspaceId ?? null,
|
||||
toWorkspaceId ?? null,
|
||||
fromWorkspace?.workspace_name ?? null,
|
||||
toWorkspace?.workspace_name ?? null,
|
||||
fromWorkspace?.owner_email ?? null,
|
||||
toWorkspace?.owner_email ?? null,
|
||||
locationLabel ?? null,
|
||||
note ?? null,
|
||||
eventDate ?? null,
|
||||
createdByUserId ?? null,
|
||||
locationDetails ?? null,
|
||||
],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
};
|
||||
|
||||
export const listBirdTimelineEvents = async (birdId: string, workspaceId: number) => {
|
||||
const result = await db.query<BirdTimelineEventRow>(
|
||||
`SELECT id, bird_id, event_type, from_workspace_id, to_workspace_id, from_workspace_name, to_workspace_name, from_owner_email, to_owner_email, location_label, location_details, note, event_date::text, created_by_user_id, created_at
|
||||
FROM bird_timeline_events
|
||||
WHERE bird_id = $1
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM birds
|
||||
WHERE birds.id = bird_timeline_events.bird_id
|
||||
AND birds.workspace_id = $2
|
||||
)
|
||||
ORDER BY event_date DESC, created_at DESC`,
|
||||
[birdId, workspaceId],
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
export const findBirdsByBandId = async (tagId: string) => {
|
||||
const result = await db.query<LostBirdMatchRow>(
|
||||
`SELECT
|
||||
@@ -367,6 +495,8 @@ export const createBird = async ({
|
||||
motivators,
|
||||
demotivators,
|
||||
favoriteSnack,
|
||||
locationLabel = null,
|
||||
locationDetails = null,
|
||||
vetClinicName = null,
|
||||
vetClinicAddress = null,
|
||||
vetAccountNumber = null,
|
||||
@@ -392,6 +522,8 @@ export const createBird = async ({
|
||||
motivators: string | null;
|
||||
demotivators: string | null;
|
||||
favoriteSnack: string | null;
|
||||
locationLabel?: string | null;
|
||||
locationDetails?: Record<string, unknown> | null;
|
||||
vetClinicName?: string | null;
|
||||
vetClinicAddress?: string | null;
|
||||
vetAccountNumber?: string | null;
|
||||
@@ -410,9 +542,9 @@ export const createBird = async ({
|
||||
publicProfileEnabled?: boolean;
|
||||
}) => {
|
||||
const result = await db.query<BirdRow>(
|
||||
`INSERT INTO birds (id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth, gotcha_day, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled)
|
||||
VALUES (COALESCE($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)
|
||||
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`,
|
||||
`INSERT INTO birds (id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, location_details, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth, gotcha_day, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled)
|
||||
VALUES (COALESCE($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26)
|
||||
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, location_details, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`,
|
||||
[
|
||||
birdId ?? null,
|
||||
workspaceId,
|
||||
@@ -422,6 +554,8 @@ export const createBird = async ({
|
||||
motivators,
|
||||
demotivators,
|
||||
favoriteSnack,
|
||||
locationLabel,
|
||||
locationDetails,
|
||||
vetClinicName,
|
||||
vetClinicAddress,
|
||||
vetAccountNumber,
|
||||
@@ -453,6 +587,8 @@ export const updateBird = async ({
|
||||
motivators,
|
||||
demotivators,
|
||||
favoriteSnack,
|
||||
locationLabel,
|
||||
locationDetails,
|
||||
vetClinicName,
|
||||
vetClinicAddress,
|
||||
vetAccountNumber,
|
||||
@@ -478,6 +614,8 @@ export const updateBird = async ({
|
||||
motivators: string | null;
|
||||
demotivators: string | null;
|
||||
favoriteSnack: string | null;
|
||||
locationLabel: string | null;
|
||||
locationDetails?: Record<string, unknown> | null;
|
||||
vetClinicName: string | null;
|
||||
vetClinicAddress: string | null;
|
||||
vetAccountNumber: string | null;
|
||||
@@ -503,26 +641,28 @@ export const updateBird = async ({
|
||||
motivators = $5,
|
||||
demotivators = $6,
|
||||
favorite_snack = $7,
|
||||
vet_clinic_name = $8,
|
||||
vet_clinic_address = $9,
|
||||
vet_account_number = $10,
|
||||
vet_doctor_name = $11,
|
||||
gender = $12,
|
||||
date_of_birth = $13,
|
||||
gotcha_day = $14,
|
||||
chart_color = $15,
|
||||
photo_data_url = $16,
|
||||
photo_object_key = $17,
|
||||
photo_content_type = $18,
|
||||
photo_updated_at = $19,
|
||||
notify_on_dob = $20,
|
||||
notify_on_gotcha_day = $21,
|
||||
public_profile_code = $22,
|
||||
public_profile_enabled = $23
|
||||
location_label = $8,
|
||||
vet_clinic_name = $9,
|
||||
vet_clinic_address = $10,
|
||||
vet_account_number = $11,
|
||||
vet_doctor_name = $12,
|
||||
gender = $13,
|
||||
date_of_birth = $14,
|
||||
gotcha_day = $15,
|
||||
chart_color = $16,
|
||||
photo_data_url = $17,
|
||||
photo_object_key = $18,
|
||||
photo_content_type = $19,
|
||||
photo_updated_at = $20,
|
||||
notify_on_dob = $21,
|
||||
notify_on_gotcha_day = $22,
|
||||
public_profile_code = $23,
|
||||
public_profile_enabled = $24,
|
||||
location_details = $25
|
||||
WHERE id = $1
|
||||
AND workspace_id = $24
|
||||
AND workspace_id = $26
|
||||
AND memorialized_at IS NULL
|
||||
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
||||
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, location_details, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
||||
(
|
||||
SELECT weight_grams::text
|
||||
FROM weight_records
|
||||
@@ -545,6 +685,7 @@ export const updateBird = async ({
|
||||
motivators,
|
||||
demotivators,
|
||||
favoriteSnack,
|
||||
locationLabel,
|
||||
vetClinicName,
|
||||
vetClinicAddress,
|
||||
vetAccountNumber,
|
||||
@@ -561,6 +702,7 @@ export const updateBird = async ({
|
||||
notifyOnGotchaDay,
|
||||
publicProfileCode,
|
||||
publicProfileEnabled,
|
||||
locationDetails ?? null,
|
||||
workspaceId,
|
||||
],
|
||||
);
|
||||
@@ -590,7 +732,7 @@ export const memorializeBird = async ({
|
||||
WHERE id = $1
|
||||
AND workspace_id = $2
|
||||
AND memorialized_at IS NULL
|
||||
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
||||
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, location_details, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
||||
(
|
||||
SELECT weight_grams::text
|
||||
FROM weight_records
|
||||
@@ -626,7 +768,7 @@ export const updateMemorialReminderPreference = async ({
|
||||
WHERE id = $1
|
||||
AND workspace_id = $2
|
||||
AND memorialized_at IS NOT NULL
|
||||
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
||||
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, location_details, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
||||
(
|
||||
SELECT weight_grams::text
|
||||
FROM weight_records
|
||||
@@ -666,7 +808,7 @@ export const transferBirdToWorkspace = async (birdId: string, sourceWorkspaceId:
|
||||
WHERE id = $1
|
||||
AND workspace_id = $2
|
||||
AND memorialized_at IS NULL
|
||||
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
||||
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, location_label, location_details, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
||||
(
|
||||
SELECT weight_grams::text
|
||||
FROM weight_records
|
||||
@@ -762,6 +904,17 @@ export const completePendingBirdTransfersForOwner = async (ownerEmail: string, t
|
||||
}
|
||||
|
||||
await markPendingBirdTransferCompleted(transfer.id, targetWorkspaceId);
|
||||
try {
|
||||
await createBirdTimelineEvent({
|
||||
birdId: bird.id,
|
||||
eventType: 'transferred',
|
||||
fromWorkspaceId: transfer.source_workspace_id,
|
||||
toWorkspaceId: targetWorkspaceId,
|
||||
createdByUserId: transfer.requested_by_user_id,
|
||||
});
|
||||
} catch (timelineError) {
|
||||
console.error('Unable to write bird timeline event', timelineError);
|
||||
}
|
||||
completed += 1;
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
@@ -809,22 +962,6 @@ export const createBirdTransferCode = async ({
|
||||
return result.rows[0] ?? null;
|
||||
};
|
||||
|
||||
export const getOpenBirdTransferCodeForBird = async (birdId: string, sourceWorkspaceId: number) => {
|
||||
const result = await db.query<BirdTransferCodeRow>(
|
||||
`SELECT id, code, bird_id, source_workspace_id, requested_by_user_id, completed_at::text, completed_workspace_id, revoked_at::text, created_at
|
||||
FROM bird_transfer_codes
|
||||
WHERE bird_id = $1
|
||||
AND source_workspace_id = $2
|
||||
AND completed_at IS NULL
|
||||
AND revoked_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1`,
|
||||
[birdId, sourceWorkspaceId],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
};
|
||||
|
||||
export const getOpenBirdTransferCode = async (code: string) => {
|
||||
const result = await db.query<
|
||||
BirdRow & {
|
||||
|
||||
@@ -531,6 +531,10 @@ test('getPlatformAdminSummary counts memorialized birds separately', async () =>
|
||||
rescue_birds: 5,
|
||||
pending_rescues: 1,
|
||||
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, /rescue_birds/);
|
||||
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;
|
||||
pending_rescues: number;
|
||||
daily_users: number;
|
||||
household_basic_subscriptions: number;
|
||||
household_plus_subscriptions: number;
|
||||
household_macaw_subscriptions: number;
|
||||
household_hyacinth_macaw_subscriptions: number;
|
||||
}>(
|
||||
`SELECT
|
||||
(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 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(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];
|
||||
|
||||
@@ -101,6 +101,8 @@ export type BirdRow = {
|
||||
motivators: string | null;
|
||||
demotivators: string | null;
|
||||
favorite_snack: string | null;
|
||||
location_label: string | null;
|
||||
location_details: Record<string, unknown> | null;
|
||||
vet_clinic_name: string | null;
|
||||
vet_clinic_address: string | null;
|
||||
vet_account_number: string | null;
|
||||
@@ -174,6 +176,26 @@ export type BirdTransferCodeRow = {
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type BirdTimelineEventType = 'profile_created' | 'transferred' | 'location_updated' | 'owner_changed' | 'manual_note';
|
||||
|
||||
export type BirdTimelineEventRow = {
|
||||
id: string;
|
||||
bird_id: string;
|
||||
event_type: BirdTimelineEventType;
|
||||
from_workspace_id: number | null;
|
||||
to_workspace_id: number | null;
|
||||
from_workspace_name: string | null;
|
||||
to_workspace_name: string | null;
|
||||
from_owner_email: string | null;
|
||||
to_owner_email: string | null;
|
||||
location_label: string | null;
|
||||
location_details: Record<string, unknown> | null;
|
||||
note: string | null;
|
||||
event_date: string;
|
||||
created_by_user_id: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type WeightRow = {
|
||||
id: string;
|
||||
bird_id: string;
|
||||
|
||||
Reference in New Issue
Block a user