staging #31
@@ -178,10 +178,6 @@ export const animals = pgTable('animals', {
|
||||
deniedAt: timestamp('denied_at', { withTimezone: true })
|
||||
});
|
||||
|
||||
// The PostGIS `location geography(Point, 4326)` column is managed outside Drizzle
|
||||
// via a trigger in the migration — it auto-syncs from lat/lng on insert/update.
|
||||
// Never write to `location` directly from application code.
|
||||
|
||||
export const sightings = pgTable('sightings', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
animalId: uuid('animal_id')
|
||||
@@ -193,7 +189,6 @@ export const sightings = pgTable('sightings', {
|
||||
seenAt: timestamp('seen_at', { withTimezone: true }).notNull(),
|
||||
lat: doublePrecision('lat').notNull(),
|
||||
lng: doublePrecision('lng').notNull(),
|
||||
// `location` geography column exists in DB but not mapped in Drizzle — use sql`` for spatial queries
|
||||
|
||||
submittedAt: timestamp('submitted_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
acceptedAt: timestamp('accepted_at', { withTimezone: true }),
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
if (filterFrom) params.set('fromDate', new Date(filterFrom).toISOString());
|
||||
if (filterTo) params.set('toDate', new Date(filterTo).toISOString());
|
||||
|
||||
const res = await fetch(`/api/map?${params}`);
|
||||
const res = await fetch(`/projects/animaldex/api/map?${params}`);
|
||||
if (!res.ok) return;
|
||||
const geojson: { features: GeoJSONFeature[] } = await res.json();
|
||||
|
||||
|
||||
@@ -3,10 +3,22 @@ import type { RequestHandler } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { animals, sightings, photos } from '$lib/server/db/schema';
|
||||
import { addSightingSchema } from '$lib/validation/sighting';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
const MAX_DISTANCE_METERS = 30_000;
|
||||
const EARTH_RADIUS_METERS = 6_371_000;
|
||||
|
||||
// Haversine distance between two lat/lng points, in meters.
|
||||
function distanceMeters(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
||||
const toRad = (deg: number) => (deg * Math.PI) / 180;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLng = toRad(lng2 - lng1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
||||
return 2 * EARTH_RADIUS_METERS * Math.asin(Math.sqrt(a));
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async ({ request, params }) => {
|
||||
const body = await request.json().catch(() => null);
|
||||
@@ -28,14 +40,7 @@ export const POST: RequestHandler = async ({ request, params }) => {
|
||||
throw error(404, { message: 'Animal not found' });
|
||||
}
|
||||
|
||||
const [distRow] = await db.execute(sql`
|
||||
SELECT ST_Distance(
|
||||
ST_MakePoint(${data.deviceLng}, ${data.deviceLat})::geography,
|
||||
ST_MakePoint(${data.lng}, ${data.lat})::geography
|
||||
) AS dist
|
||||
`);
|
||||
|
||||
const dist = Number((distRow as Record<string, unknown>).dist);
|
||||
const dist = distanceMeters(data.deviceLat, data.deviceLng, data.lat, data.lng);
|
||||
if (dist > MAX_DISTANCE_METERS) {
|
||||
throw error(422, {
|
||||
message: `Location is ${Math.round(dist / 1000)}km from your device. Maximum is 30km.`
|
||||
|
||||
@@ -20,10 +20,10 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
isNull(animals.deniedAt),
|
||||
isNotNull(sightings.acceptedAt),
|
||||
isNull(sightings.deniedAt),
|
||||
sql`ST_Within(
|
||||
${sightings}.location::geometry,
|
||||
ST_MakeEnvelope(${q.minLng}, ${q.minLat}, ${q.maxLng}, ${q.maxLat}, 4326)
|
||||
)`
|
||||
gte(sightings.lat, q.minLat),
|
||||
lte(sightings.lat, q.maxLat),
|
||||
gte(sightings.lng, q.minLng),
|
||||
lte(sightings.lng, q.maxLng)
|
||||
];
|
||||
|
||||
if (q.species) conditions.push(ilike(animals.species, `%${q.species}%`));
|
||||
|
||||
Reference in New Issue
Block a user