From e06dae91a3c8862997f09760b23ad20eb6606230 Mon Sep 17 00:00:00 2001 From: Corey Blais Date: Fri, 17 Apr 2026 17:11:11 -0400 Subject: [PATCH] Added missing bird --- backend/src/app.ts | 149 +++++++++++++++++- backend/src/repositories/birdRepository.ts | 26 ++- .../src/repositories/workspaceRepository.ts | 21 +++ backend/src/types.ts | 5 + frontend/src/App.tsx | 106 +++++++++++++ frontend/src/index.css | 63 +++++++- 6 files changed, 366 insertions(+), 4 deletions(-) diff --git a/backend/src/app.ts b/backend/src/app.ts index fdcaf1d..e22c686 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -31,6 +31,7 @@ import { completePendingBirdTransfersForOwner, createBird, createPendingBirdTransfer, + findBirdsByBandId, createVetVisitForBird, createWeightForBird, deleteBird, @@ -60,6 +61,7 @@ import { listOwnedWorkspacesByOwnerEmail, listRescueWorkspacesForAdmin, listMembershipsForUser, + listWorkspaceNotificationEmails, listWorkspaceMembers, setWorkspaceStripeCustomerId, setWorkspaceStripeSubscription, @@ -75,6 +77,7 @@ import type { BirdGender, BirdRow, IntegrationTokenRow, + LostBirdMatchRow, ProviderKey, RescueVerificationStatus, SubscriptionStatus, @@ -108,7 +111,16 @@ if (trustProxy) { app.set('trust proxy', trustProxy === 'true' ? true : Number(trustProxy) || trustProxy); } -const defaultAllowedOrigins = ['http://localhost:3000', 'http://127.0.0.1:3000', 'http://localhost:5173', 'http://127.0.0.1:5173']; +const defaultAllowedOrigins = [ + 'http://localhost:3000', + 'http://127.0.0.1:3000', + 'http://localhost:5173', + 'http://127.0.0.1:5173', + 'http://localhost:8088', + 'http://127.0.0.1:8088', + 'https://flockpal.app', + 'https://www.flockpal.app', +]; const allowedOrigins = Array.from( new Set( @@ -172,6 +184,14 @@ const flockTransferSchema = z.object({ destinationOwnerEmail: z.string().trim().email().max(255), }); +const lostBirdReportSchema = z.object({ + tagId: z.string().trim().min(1).max(80), + finderName: z.string().trim().max(160).optional().or(z.literal('')), + finderEmail: z.string().trim().email().max(255).optional().or(z.literal('')), + foundLocation: z.string().trim().max(255).optional().or(z.literal('')), + message: z.string().trim().max(1000).optional().or(z.literal('')), +}); + const birdSchema = z.object({ name: z.string().trim().min(1).max(120), tagId: z.string().trim().min(1).max(80), @@ -456,6 +476,13 @@ app.use( legacyHeaders: false, }), ); +const lostBirdReportLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + limit: 10, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many found bird reports. 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.' }); @@ -846,6 +873,71 @@ const issueBirdTransferInvite = async ({ }; }; +const sendLostBirdReportNotification = async ({ + bird, + recipients, + report, +}: { + bird: LostBirdMatchRow; + recipients: string[]; + report: z.infer; +}) => { + const uniqueRecipients = Array.from(new Set(recipients.map((email) => normalizeEmail(email)).filter(Boolean))); + + if (!uniqueRecipients.length) { + return { delivered: false }; + } + + const finderName = emptyToNull(report.finderName) ?? 'Not provided'; + const finderEmail = emptyToNull(report.finderEmail) ?? 'Not provided'; + const foundLocation = emptyToNull(report.foundLocation) ?? 'Not provided'; + const message = emptyToNull(report.message) ?? 'Not provided'; + const subject = `Possible found bird report for ${bird.name}`; + const lines = [ + `A possible found bird report was submitted for ${bird.name}.`, + '', + `Band ID: ${bird.tag_id}`, + `Species: ${bird.species}`, + `Flock: ${bird.workspace_name}`, + '', + `Finder name: ${finderName}`, + `Finder email: ${finderEmail}`, + `Found location: ${foundLocation}`, + `Message: ${message}`, + '', + 'FlockPal does not verify found bird reports. Please use care before sharing personal information or arranging a pickup.', + ]; + + if (!mailTransport) { + console.log(`Found bird report for ${uniqueRecipients.join(', ')}:\n${lines.join('\n')}`); + return { delivered: false }; + } + + await mailTransport.sendMail({ + from: smtpFromName ? `"${smtpFromName}" <${smtpFromEmail}>` : smtpFromEmail, + to: smtpFromEmail, + bcc: uniqueRecipients, + replyTo: emptyToNull(report.finderEmail) ?? undefined, + subject, + text: lines.join('\n'), + html: ` +

A possible found bird report was submitted for ${escapeHtml(bird.name)}.

+ +

FlockPal does not verify found bird reports. Please use care before sharing personal information or arranging a pickup.

+ `, + }); + + return { delivered: true }; +}; + const readBearerToken = (authorizationHeader?: string) => { if (!authorizationHeader) { return ''; @@ -956,6 +1048,61 @@ app.get('/api/health', (_req: Request, res: Response) => { res.json({ ok: true }); }); +app.post('/api/lost-bird/report', lostBirdReportLimiter, async (req: Request, res: Response) => { + const parsed = lostBirdReportSchema.safeParse(req.body); + + if (!parsed.success) { + res.status(400).json({ error: 'Invalid found bird report', details: parsed.error.flatten() }); + return; + } + + try { + const matches = await findBirdsByBandId(parsed.data.tagId); + let deliveredCount = 0; + + for (const bird of matches) { + try { + const recipients = await listWorkspaceNotificationEmails(bird.workspace_id); + const delivery = await sendLostBirdReportNotification({ + bird, + recipients, + report: parsed.data, + }); + + if (delivery.delivered) { + deliveredCount += 1; + } + } catch (error) { + console.error('Lost bird notification failed', error); + } + } + + if (!matches.length) { + res.json({ + status: 'not_found', + message: 'That band ID is not currently in the FlockPal system.', + }); + return; + } + + if (deliveredCount > 0) { + res.json({ + status: 'contacted', + message: 'A matching bird was found and the flock contacts were notified.', + }); + return; + } + + res.status(503).json({ + status: 'not_contacted', + error: 'A matching bird was found, but FlockPal could not notify the flock right now. Please contact FlockPal support.', + }); + } catch (error) { + console.error('Lost bird report handling failed', error); + res.status(500).json({ error: 'Unable to process this found bird report right now.' }); + } +}); + app.get('/api/auth/providers', (_req: Request, res: Response) => { res.json({ providers: Object.values(oauthProviders).map((provider) => ({ diff --git a/backend/src/repositories/birdRepository.ts b/backend/src/repositories/birdRepository.ts index 75ca554..c35a485 100644 --- a/backend/src/repositories/birdRepository.ts +++ b/backend/src/repositories/birdRepository.ts @@ -1,5 +1,5 @@ import { db } from '../db/client.js'; -import type { BirdGender, BirdRow, PendingBirdTransferRow, VetVisitRow, WeightRow } from '../types.js'; +import type { BirdGender, BirdRow, LostBirdMatchRow, PendingBirdTransferRow, VetVisitRow, WeightRow } from '../types.js'; const birdSelectFields = ` birds.id, @@ -59,6 +59,30 @@ export const listBirds = async (workspaceId: number) => { return result.rows; }; +export const findBirdsByBandId = async (tagId: string) => { + const result = await db.query( + `SELECT + ${birdSelectFields}, + workspaces.name AS workspace_name, + workspaces.billing_email AS workspace_billing_email + FROM birds + INNER JOIN workspaces ON workspaces.id = birds.workspace_id + LEFT JOIN LATERAL ( + SELECT weight_grams, recorded_on + FROM weight_records + WHERE weight_records.bird_id = birds.id + ORDER BY recorded_on DESC + LIMIT 1 + ) latest ON TRUE + WHERE LOWER(birds.tag_id) = LOWER($1) + ORDER BY birds.created_at ASC + LIMIT 10`, + [tagId], + ); + + return result.rows; +}; + export const createBird = async ({ workspaceId, name, diff --git a/backend/src/repositories/workspaceRepository.ts b/backend/src/repositories/workspaceRepository.ts index 2c904b5..960e33f 100644 --- a/backend/src/repositories/workspaceRepository.ts +++ b/backend/src/repositories/workspaceRepository.ts @@ -250,6 +250,27 @@ export const listWorkspaceMembers = async (workspaceId: number) => { return result.rows; }; +export const listWorkspaceNotificationEmails = async (workspaceId: number) => { + const result = await db.query<{ email: string }>( + `SELECT DISTINCT LOWER(TRIM(email)) AS email + FROM ( + SELECT COALESCE(invite_email, email) AS email + FROM workspace_members + WHERE workspace_id = $1 + UNION + SELECT billing_email AS email + FROM workspaces + WHERE id = $1 + ) contact_emails + WHERE email IS NOT NULL + AND TRIM(email) <> '' + ORDER BY email ASC`, + [workspaceId], + ); + + return result.rows.map((row) => row.email); +}; + export const findAlternateWorkspaceForUser = async (userId: string, excludeWorkspaceId: number) => { const result = await db.query<{ workspace_id: number }>( `SELECT workspace_id diff --git a/backend/src/types.ts b/backend/src/types.ts index e01f79d..02f3926 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -110,6 +110,11 @@ export type BirdRow = { latest_recorded_on: string | null; }; +export type LostBirdMatchRow = BirdRow & { + workspace_name: string; + workspace_billing_email: string | null; +}; + export type PendingBirdTransferRow = { id: string; bird_id: string; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index eac4831..25e0102 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -176,6 +176,13 @@ type AuthFormState = { email: string; }; +type LostBirdReportFormState = { + tagId: string; + finderEmail: string; + foundLocation: string; + message: string; +}; + type AuthNotice = { message: string; previewUrl?: string | null; @@ -280,6 +287,13 @@ const emptyAuthForm: AuthFormState = { email: '', }; +const emptyLostBirdReportForm: LostBirdReportFormState = { + tagId: '', + finderEmail: '', + foundLocation: '', + message: '', +}; + const emptyIntegrationTokenForm: IntegrationTokenFormState = { name: '', scope: 'read_write', @@ -867,6 +881,9 @@ function App() { const [authNotice, setAuthNotice] = useState(null); const [authLoading, setAuthLoading] = useState(true); const [authSubmitting, setAuthSubmitting] = useState(false); + const [lostBirdReportForm, setLostBirdReportForm] = useState(emptyLostBirdReportForm); + const [lostBirdReportNotice, setLostBirdReportNotice] = useState<{ message: string; kind: 'success' | 'error' } | null>(null); + const [lostBirdReportSubmitting, setLostBirdReportSubmitting] = useState(false); const [workspace, setWorkspace] = useState(null); const [activeMembership, setActiveMembership] = useState(null); const [workspaceMembers, setWorkspaceMembers] = useState([]); @@ -1533,6 +1550,43 @@ function App() { } }; + const handleLostBirdReportSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setLostBirdReportNotice(null); + setLostBirdReportSubmitting(true); + + try { + const response = await apiFetch('/lost-bird/report', undefined, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tagId: lostBirdReportForm.tagId.trim(), + finderEmail: lostBirdReportForm.finderEmail.trim(), + foundLocation: lostBirdReportForm.foundLocation.trim(), + message: lostBirdReportForm.message.trim(), + }), + }); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Unable to send this report right now.')); + } + + const data = (await readJsonSafely<{ message?: string }>(response)) ?? {}; + setLostBirdReportNotice({ + message: data.message ?? 'Report received.', + kind: 'success', + }); + setLostBirdReportForm(emptyLostBirdReportForm); + } catch (reportError) { + setLostBirdReportNotice({ + message: reportError instanceof Error ? reportError.message : 'Unable to send this report right now.', + kind: 'error', + }); + } finally { + setLostBirdReportSubmitting(false); + } + }; + const handleLogout = async () => { setError(''); @@ -2610,6 +2664,57 @@ function App() {

Keep every bird's care story in one place; your flock's health, history, and routines together and easier to visualize.

+
+ + + Report a missing bird + + +

Enter the band ID and FlockPal will notify the flock if that bird is in the system.

+
+ + + +