diff --git a/.env.example b/.env.example index 1ed0bfe..d97f1a7 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,7 @@ PHOTO_DELIVERY_MODE=proxy FRONTEND_URL=http://localhost:3000 BACKEND_URL=http://localhost:5000 VITE_API_BASE_URL=http://localhost:5000/api +MAPBOX_ACCESS_TOKEN= NODE_ENV=development TRUST_PROXY= ADMIN_EMAILS=corey@blaishome.online diff --git a/backend/src/app.ts b/backend/src/app.ts index ac910ac..1af5330 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -288,6 +288,7 @@ const birdProfileListSchema = z 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('')), @@ -295,10 +296,16 @@ const verifiedLocationDetailsSchema = z 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('')), @@ -367,12 +374,16 @@ const normalizeVerifiedLocationDetails = (value: VerifiedLocationDetailsInput) = 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 (!city && !region && !country && !countryCode && latitude === null && longitude === null) { + if (!label && !city && !region && !country && !countryCode && latitude === null && longitude === null) { return null; } return { + label, city, region, country, @@ -380,12 +391,93 @@ const normalizeVerifiedLocationDetails = (value: VerifiedLocationDetailsInput) = latitude, longitude, precision, + provider, + providerPlaceId, verifiedAt: new Date().toISOString(), }; }; const formatVerifiedLocationLabel = (details: ReturnType) => - details ? [details.city, details.region, details.country].filter(Boolean).join(', ') || null : null; + details ? details.label || [details.city, details.region, details.country].filter(Boolean).join(', ') || null : null; + +type VerifiedLocationSearchResult = NonNullable>; + +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(); +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), @@ -966,6 +1058,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.' }); @@ -3652,6 +3751,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', '5'); + 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([ diff --git a/backend/src/repositories/birdRepository.ts b/backend/src/repositories/birdRepository.ts index 408b81f..dec5aad 100644 --- a/backend/src/repositories/birdRepository.ts +++ b/backend/src/repositories/birdRepository.ts @@ -208,7 +208,21 @@ export const createBirdTimelineEvent = async ({ created_by_user_id, location_details ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, COALESCE($9::text, $6::text, $5::text), $10, COALESCE($11::date, CURRENT_DATE), $12, $13) + 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, diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 618df0e..a9bae56 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -55,6 +55,7 @@ services: PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy} FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production} BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production} + MAPBOX_ACCESS_TOKEN: ${MAPBOX_ACCESS_TOKEN:-} ADMIN_EMAILS: ${ADMIN_EMAILS:-} RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app} RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee} @@ -136,6 +137,7 @@ services: PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy} FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production} BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production} + MAPBOX_ACCESS_TOKEN: ${MAPBOX_ACCESS_TOKEN:-} ADMIN_EMAILS: ${ADMIN_EMAILS:-} RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app} RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee} diff --git a/docker-compose.yml b/docker-compose.yml index 3d6fde6..b7f4446 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -53,6 +53,7 @@ services: PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy} FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000} BACKEND_URL: ${BACKEND_URL:-http://localhost:5000} + MAPBOX_ACCESS_TOKEN: ${MAPBOX_ACCESS_TOKEN:-} ADMIN_EMAILS: ${ADMIN_EMAILS:-} RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app} RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee} @@ -129,6 +130,7 @@ services: PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy} FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000} BACKEND_URL: ${BACKEND_URL:-http://localhost:5000} + MAPBOX_ACCESS_TOKEN: ${MAPBOX_ACCESS_TOKEN:-} ADMIN_EMAILS: ${ADMIN_EMAILS:-} RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app} RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 890714d..98c8045 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react'; import birdSilhouette from './assets/bird-silhouette.jpg'; import flockPalLandingArt from './assets/flockpal-landing-art.png'; import flockPalTextArt from './assets/flockpal-text.png'; @@ -17,6 +17,7 @@ type IntegrationTokenScope = 'read_only' | 'read_write'; type BirdGender = 'unknown' | 'male' | 'female' | 'male_dna' | 'female_dna'; type VerifiedLocationDetails = { + label?: string | null; city: string; region: string; country: string; @@ -24,9 +25,18 @@ type VerifiedLocationDetails = { latitude: number | null; longitude: number | null; precision: 'city' | 'region' | 'country'; + provider?: 'mapbox' | null; + providerPlaceId?: string | null; verifiedAt?: string; }; +type LocationSearchState = { + query: string; + results: VerifiedLocationDetails[]; + searching: boolean; + error: string; +}; + type Bird = { id: string; workspaceId?: number; @@ -713,6 +723,7 @@ const parseBirdImportRows = (rows: Record[]): BirdImportPreview }; const emptyVerifiedLocationDetails: VerifiedLocationDetails = { + label: '', city: '', region: '', country: '', @@ -720,11 +731,21 @@ const emptyVerifiedLocationDetails: VerifiedLocationDetails = { latitude: null, longitude: null, precision: 'city', + provider: null, + providerPlaceId: '', +}; + +const emptyLocationSearchState: LocationSearchState = { + query: '', + results: [], + searching: false, + error: '', }; const normalizeVerifiedLocationDetails = (details: Partial | null | undefined): VerifiedLocationDetails => ({ ...emptyVerifiedLocationDetails, ...details, + label: details?.label ?? '', city: details?.city ?? '', region: details?.region ?? '', country: details?.country ?? '', @@ -732,10 +753,95 @@ const normalizeVerifiedLocationDetails = (details: Partial - [details.city.trim(), details.region.trim(), details.country.trim()].filter(Boolean).join(', '); + details.label?.trim() || [details.city.trim(), details.region.trim(), details.country.trim()].filter(Boolean).join(', '); + +type VerifiedLocationSearchFieldProps = { + label: string; + location: VerifiedLocationDetails; + fallbackLabel?: string | null; + searchState: LocationSearchState; + onSearchStateChange: (state: LocationSearchState) => void; + onSearch: () => void; + onSelect: (location: VerifiedLocationDetails) => void; + onClear: () => void; +}; + +const VerifiedLocationSearchField = ({ + label, + location, + fallbackLabel, + searchState, + onSearchStateChange, + onSearch, + onSelect, + onClear, +}: VerifiedLocationSearchFieldProps) => { + const selectedLabel = formatVerifiedLocationLabel(location) || fallbackLabel || ''; + + return ( +
+
+ {label} +
+ onSearchStateChange({ ...searchState, query: event.target.value, error: '' })} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + onSearch(); + } + }} + placeholder="Search city, region, or country" + /> + +
+
+ {selectedLabel ? ( +
+
+ {selectedLabel} + {location.provider === 'mapbox' ? 'Verified by Mapbox' : 'Saved location'} +
+ +
+ ) : null} + {searchState.error ? ( +

+ {searchState.error} +

+ ) : null} + {searchState.results.length ? ( +
+ {searchState.results.map((result) => { + const resultLabel = formatVerifiedLocationLabel(result); + return ( + + ); + })} +
+ ) : null} +
+ ); +}; const emptyBirdForm: BirdFormState = { name: '', @@ -1846,6 +1952,8 @@ function App() { const [birdTimelineLoading, setBirdTimelineLoading] = useState(false); const [savingBirdTimelineEvent, setSavingBirdTimelineEvent] = useState(false); const [birdTimelineNotice, setBirdTimelineNotice] = useState<{ message: string; kind: 'success' | 'error' } | null>(null); + const [birdLocationSearch, setBirdLocationSearch] = useState(emptyLocationSearchState); + const [timelineLocationSearch, setTimelineLocationSearch] = useState(emptyLocationSearchState); const [revokingIntegrationTokenId, setRevokingIntegrationTokenId] = useState(''); const [newIntegrationTokenSecret, setNewIntegrationTokenSecret] = useState(''); const [updatingRescueWorkspaceId, setUpdatingRescueWorkspaceId] = useState(null); @@ -1954,6 +2062,7 @@ function App() { setBirdTimelineEvents([]); setBirdTimelineEventForm(emptyBirdTimelineEventForm); setBirdTimelineNotice(null); + setTimelineLocationSearch(emptyLocationSearchState); }, [selectedBirdId]); useEffect(() => { @@ -2893,6 +3002,7 @@ function App() { if (!editingBird) { setEditingBirdId(''); setBirdForm(emptyBirdForm); + setBirdLocationSearch(emptyLocationSearchState); setBirdPhotoName(''); setPhotoCrop(null); setPhotoDrag(null); @@ -2900,6 +3010,7 @@ function App() { } setBirdForm(toBirdForm(editingBird)); + setBirdLocationSearch(emptyLocationSearchState); setBirdPhotoName(''); setPhotoCrop(null); setPhotoDrag(null); @@ -2928,6 +3039,7 @@ function App() { setEditingBirdId(''); setBirdEditorOpen(true); setBirdForm(emptyBirdForm); + setBirdLocationSearch(emptyLocationSearchState); setBirdPhotoName(''); setPhotoCrop(null); setPhotoDrag(null); @@ -2935,6 +3047,42 @@ function App() { setActivePage('flock'); }; + const searchVerifiedLocations = async ( + searchState: LocationSearchState, + setSearchState: Dispatch>, + ) => { + const query = searchState.query.replace(/\s+/g, ' ').trim(); + + if (query.length < 3) { + setSearchState((current) => ({ ...current, error: 'Enter at least 3 characters to search.', results: [] })); + return; + } + + setSearchState((current) => ({ ...current, searching: true, error: '', results: [] })); + + try { + const response = await apiFetch(`/locations/search?q=${encodeURIComponent(query)}`, authToken); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Unable to search locations.')); + } + + const data = (await readJsonSafely<{ results?: VerifiedLocationDetails[] }>(response)) ?? {}; + setSearchState((current) => ({ + ...current, + searching: false, + results: (data.results ?? []).map(normalizeVerifiedLocationDetails), + error: data.results?.length ? '' : 'No matching city, region, or country found.', + })); + } catch (searchError) { + setSearchState((current) => ({ + ...current, + searching: false, + error: searchError instanceof Error ? searchError.message : 'Unable to search locations.', + })); + } + }; + const handleAuthSubmit = async (event: React.FormEvent) => { event.preventDefault(); setError(''); @@ -6580,105 +6728,23 @@ function App() {

Location

Mappable location

- - - - - - - + searchVerifiedLocations(birdLocationSearch, setBirdLocationSearch)} + onSelect={(location) => { + const locationLabel = formatVerifiedLocationLabel(location); + setBirdForm({ ...birdForm, locationDetails: location, locationLabel }); + setBirdLocationSearch({ ...emptyLocationSearchState, query: locationLabel }); + }} + onClear={() => { + setBirdForm({ ...birdForm, locationDetails: emptyVerifiedLocationDetails, locationLabel: '' }); + setBirdLocationSearch(emptyLocationSearchState); + }} + />
Gender
@@ -7899,6 +7965,9 @@ function App() { value={birdTimelineEventForm.eventType} onChange={(event) => { const eventType = event.target.value as BirdTimelineEventFormState['eventType']; + if (eventType === 'owner_changed') { + setTimelineLocationSearch(emptyLocationSearchState); + } setBirdTimelineEventForm({ ...birdTimelineEventForm, eventType, @@ -7924,114 +7993,31 @@ function App() { /> {birdTimelineEventForm.eventType !== 'owner_changed' ? ( - <> - - - - - - - - + searchVerifiedLocations(timelineLocationSearch, setTimelineLocationSearch)} + onSelect={(location) => { + const locationLabel = formatVerifiedLocationLabel(location); + setBirdTimelineEventForm({ + ...birdTimelineEventForm, + locationDetails: location, + locationLabel, + }); + setTimelineLocationSearch({ ...emptyLocationSearchState, query: locationLabel }); + }} + onClear={() => { + setBirdTimelineEventForm({ + ...birdTimelineEventForm, + locationDetails: emptyVerifiedLocationDetails, + locationLabel: '', + }); + setTimelineLocationSearch(emptyLocationSearchState); + }} + /> ) : null} {birdTimelineEventForm.eventType === 'location_updated' ? (