Added mapbox integration
Deploy / deploy-dev (push) Successful in 2m33s
Deploy / deploy-prod (push) Has been skipped

This commit is contained in:
blaisadmin
2026-06-29 19:50:08 -04:00
parent 9ddd85b5c4
commit 7ef20ab0fb
7 changed files with 434 additions and 213 deletions
+156 -2
View File
@@ -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<typeof normalizeVerifiedLocationDetails>) =>
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<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),
@@ -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([