Added missing bird
This commit is contained in:
+148
-1
@@ -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) => ({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user