Added missing bird

This commit is contained in:
Corey Blais
2026-04-17 17:11:11 -04:00
parent 328a9a704d
commit e06dae91a3
6 changed files with 366 additions and 4 deletions
+148 -1
View File
@@ -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<typeof lostBirdReportSchema>;
}) => {
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: `
<p>A possible found bird report was submitted for <strong>${escapeHtml(bird.name)}</strong>.</p>
<ul>
<li><strong>Band ID:</strong> ${escapeHtml(bird.tag_id)}</li>
<li><strong>Species:</strong> ${escapeHtml(bird.species)}</li>
<li><strong>Flock:</strong> ${escapeHtml(bird.workspace_name)}</li>
<li><strong>Finder name:</strong> ${escapeHtml(finderName)}</li>
<li><strong>Finder email:</strong> ${escapeHtml(finderEmail)}</li>
<li><strong>Found location:</strong> ${escapeHtml(foundLocation)}</li>
<li><strong>Message:</strong> ${escapeHtml(message)}</li>
</ul>
<p>FlockPal does not verify found bird reports. Please use care before sharing personal information or arranging a pickup.</p>
`,
});
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) => ({
+25 -1
View File
@@ -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<LostBirdMatchRow>(
`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,
@@ -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
+5
View File
@@ -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;
+106
View File
@@ -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<AuthNotice | null>(null);
const [authLoading, setAuthLoading] = useState(true);
const [authSubmitting, setAuthSubmitting] = useState(false);
const [lostBirdReportForm, setLostBirdReportForm] = useState<LostBirdReportFormState>(emptyLostBirdReportForm);
const [lostBirdReportNotice, setLostBirdReportNotice] = useState<{ message: string; kind: 'success' | 'error' } | null>(null);
const [lostBirdReportSubmitting, setLostBirdReportSubmitting] = useState(false);
const [workspace, setWorkspace] = useState<Workspace | null>(null);
const [activeMembership, setActiveMembership] = useState<WorkspaceMember | null>(null);
const [workspaceMembers, setWorkspaceMembers] = useState<WorkspaceMember[]>([]);
@@ -1533,6 +1550,43 @@ function App() {
}
};
const handleLostBirdReportSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
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() {
<p className="muted">
Keep every bird's care story in one place; your flock's health, history, and routines together and easier to visualize.
</p>
<details className="summary-card lost-bird-login-card">
<summary>
<span>
<span className="eyebrow">Report a missing bird</span>
</span>
</summary>
<p className="muted">Enter the band ID and FlockPal will notify the flock if that bird is in the system.</p>
<form className="form-panel" onSubmit={handleLostBirdReportSubmit}>
<label>
Bird band ID
<input
value={lostBirdReportForm.tagId}
onChange={(event) => setLostBirdReportForm({ ...lostBirdReportForm, tagId: event.target.value })}
placeholder="Example: ABC-123"
required
/>
</label>
<label>
Your email
<input
type="email"
value={lostBirdReportForm.finderEmail}
onChange={(event) => setLostBirdReportForm({ ...lostBirdReportForm, finderEmail: event.target.value })}
placeholder="Optional, but helpful"
/>
</label>
<label>
Where was the bird found?
<input
value={lostBirdReportForm.foundLocation}
onChange={(event) => setLostBirdReportForm({ ...lostBirdReportForm, foundLocation: event.target.value })}
placeholder="City, neighborhood, or nearby landmark"
/>
</label>
<label>
Message for the flock
<textarea
value={lostBirdReportForm.message}
onChange={(event) => setLostBirdReportForm({ ...lostBirdReportForm, message: event.target.value })}
placeholder="Add any safe details that could help the flock contact you."
rows={3}
/>
</label>
<button className="primary-button" type="submit" disabled={lostBirdReportSubmitting}>
{lostBirdReportSubmitting ? 'Sending report...' : 'Notify the flock'}
</button>
</form>
{lostBirdReportNotice ? (
<p className={lostBirdReportNotice.kind === 'error' ? 'error-banner' : 'success-banner'}>{lostBirdReportNotice.message}</p>
) : null}
</details>
</div>
<div className="auth-card">
@@ -2676,6 +2781,7 @@ function App() {
</a>
))}
</div>
</div>
</section>
</main>
+61 -2
View File
@@ -152,7 +152,7 @@ textarea {
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: 1.5rem;
align-items: start;
align-items: stretch;
}
.auth-hero-card {
@@ -167,7 +167,10 @@ textarea {
}
.auth-copy {
align-content: start;
position: relative;
align-content: stretch;
grid-template-rows: auto auto auto 1fr auto;
padding-bottom: 3.75rem;
}
.auth-card {
@@ -177,6 +180,53 @@ textarea {
border: 1px solid var(--panel-border);
}
.lost-bird-login-card {
position: absolute;
right: 0;
bottom: 0;
left: 0;
z-index: 2;
gap: 0.65rem;
padding: 0.75rem 0.85rem;
}
.lost-bird-login-card[open] {
box-shadow: 0 24px 48px rgba(86, 63, 34, 0.24);
}
.lost-bird-login-card summary {
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
list-style: none;
}
.lost-bird-login-card summary::-webkit-details-marker {
display: none;
}
.lost-bird-login-card summary::after {
content: "Open";
border: 1px solid rgba(39, 105, 179, 0.18);
border-radius: 999px;
padding: 0.45rem 0.75rem;
background: rgba(255, 254, 250, 0.72);
color: var(--accent-green);
font-size: 0.82rem;
font-weight: 800;
}
.lost-bird-login-card[open] summary::after {
content: "Close";
}
.lost-bird-login-card .eyebrow {
display: block;
margin-bottom: 0;
}
.auth-illustration-card {
margin: 0;
padding: 1rem;
@@ -1192,6 +1242,15 @@ label {
color: #922728;
}
.success-banner {
margin: 0;
padding: 1rem 1.2rem;
border-radius: 18px;
background: rgba(35, 138, 90, 0.1);
border: 1px solid rgba(35, 138, 90, 0.2);
color: var(--accent-green);
}
.picker-list {
display: flex;
gap: 0.75rem;