Added mapbox integration
This commit is contained in:
+156
-2
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user