feat: Configure database with Prisma and MySQL spatial support (Task 22)
- Set up MySQL 8.0+ with spatial data support (POINT SRID 4326) - Created Prisma schema with all models (User, Report, Event, etc.) - Added Clerk authentication support (clerk_id field) - Configured spatial indexes on location fields for performance - Created database migrations and applied to MySQL - Built spatial query utilities for geographical operations - Added database connection singleton and health checks - Created comprehensive documentation and test scripts Requirements: 1.1, 2.1, 3.2, 4.1
This commit is contained in:
315
server/src/utils/spatial.ts
Normal file
315
server/src/utils/spatial.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* Spatial utility functions for working with MySQL spatial data types
|
||||
* and performing geographical calculations
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* Create a POINT geometry from latitude and longitude
|
||||
* @param latitude - Latitude in decimal degrees
|
||||
* @param longitude - Longitude in decimal degrees
|
||||
* @returns SQL fragment for creating a POINT
|
||||
*/
|
||||
export function createPoint(latitude: number, longitude: number): string {
|
||||
return `ST_GeomFromText('POINT(${longitude} ${latitude})', 4326)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find reports within a radius of a given location
|
||||
* @param latitude - Center latitude
|
||||
* @param longitude - Center longitude
|
||||
* @param radiusMeters - Radius in meters
|
||||
* @param limit - Maximum number of results
|
||||
* @returns Array of reports with distance
|
||||
*/
|
||||
export async function findReportsNearby(
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
radiusMeters: number,
|
||||
limit: number = 50
|
||||
) {
|
||||
const results = await prisma.$queryRaw<Array<{
|
||||
id: string;
|
||||
user_id: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
litter_type: string;
|
||||
quantity: string;
|
||||
distance: number;
|
||||
}>>`
|
||||
SELECT
|
||||
id,
|
||||
user_id,
|
||||
latitude,
|
||||
longitude,
|
||||
litter_type,
|
||||
quantity,
|
||||
ST_Distance_Sphere(
|
||||
location,
|
||||
ST_GeomFromText(${`POINT(${longitude} ${latitude})`}, 4326)
|
||||
) as distance
|
||||
FROM reports
|
||||
WHERE ST_Distance_Sphere(
|
||||
location,
|
||||
ST_GeomFromText(${`POINT(${longitude} ${latitude})`}, 4326)
|
||||
) <= ${radiusMeters}
|
||||
ORDER BY distance
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find events within a radius of a given location
|
||||
* @param latitude - Center latitude
|
||||
* @param longitude - Center longitude
|
||||
* @param radiusMeters - Radius in meters
|
||||
* @param limit - Maximum number of results
|
||||
* @returns Array of events with distance
|
||||
*/
|
||||
export async function findEventsNearby(
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
radiusMeters: number,
|
||||
limit: number = 50
|
||||
) {
|
||||
const results = await prisma.$queryRaw<Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
location_name: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
scheduled_date: Date;
|
||||
distance: number;
|
||||
}>>`
|
||||
SELECT
|
||||
id,
|
||||
title,
|
||||
location_name,
|
||||
latitude,
|
||||
longitude,
|
||||
scheduled_date,
|
||||
ST_Distance_Sphere(
|
||||
location,
|
||||
ST_GeomFromText(${`POINT(${longitude} ${latitude})`}, 4326)
|
||||
) as distance
|
||||
FROM events
|
||||
WHERE status = 'upcoming'
|
||||
AND ST_Distance_Sphere(
|
||||
location,
|
||||
ST_GeomFromText(${`POINT(${longitude} ${latitude})`}, 4326)
|
||||
) <= ${radiusMeters}
|
||||
ORDER BY distance
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate hotspots based on report clustering
|
||||
* Identifies areas with 5+ reports within 500m in the last 30 days
|
||||
* @returns Array of hotspot locations with statistics
|
||||
*/
|
||||
export async function calculateHotspots() {
|
||||
const results = await prisma.$queryRaw<Array<{
|
||||
center_lat: number;
|
||||
center_lng: number;
|
||||
report_count: number;
|
||||
avg_severity: number;
|
||||
last_report_date: Date;
|
||||
}>>`
|
||||
SELECT
|
||||
AVG(latitude) as center_lat,
|
||||
AVG(longitude) as center_lng,
|
||||
COUNT(*) as report_count,
|
||||
AVG(CASE
|
||||
WHEN quantity = 'minimal' THEN 1
|
||||
WHEN quantity = 'moderate' THEN 2
|
||||
WHEN quantity = 'significant' THEN 3
|
||||
WHEN quantity = 'severe' THEN 4
|
||||
ELSE 0
|
||||
END) as avg_severity,
|
||||
MAX(created_at) as last_report_date
|
||||
FROM reports
|
||||
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
|
||||
GROUP BY ST_GeoHash(location, 7)
|
||||
HAVING COUNT(*) >= 5
|
||||
ORDER BY report_count DESC, avg_severity DESC
|
||||
`;
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate distance between two points in meters
|
||||
* @param lat1 - First point latitude
|
||||
* @param lon1 - First point longitude
|
||||
* @param lat2 - Second point latitude
|
||||
* @param lon2 - Second point longitude
|
||||
* @returns Distance in meters
|
||||
*/
|
||||
export async function calculateDistance(
|
||||
lat1: number,
|
||||
lon1: number,
|
||||
lat2: number,
|
||||
lon2: number
|
||||
): Promise<number> {
|
||||
const result = await prisma.$queryRaw<Array<{ distance: number }>>`
|
||||
SELECT ST_Distance_Sphere(
|
||||
ST_GeomFromText(${`POINT(${lon1} ${lat1})`}, 4326),
|
||||
ST_GeomFromText(${`POINT(${lon2} ${lat2})`}, 4326)
|
||||
) as distance
|
||||
`;
|
||||
|
||||
return result[0]?.distance || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if coordinates are within Irish boundaries
|
||||
* Approximate bounding box for Ireland
|
||||
* @param latitude - Latitude to validate
|
||||
* @param longitude - Longitude to validate
|
||||
* @returns True if coordinates are within Ireland
|
||||
*/
|
||||
export function isWithinIreland(latitude: number, longitude: number): boolean {
|
||||
// Ireland bounding box (approximate)
|
||||
const MIN_LAT = 51.4;
|
||||
const MAX_LAT = 55.4;
|
||||
const MIN_LNG = -10.5;
|
||||
const MAX_LNG = -5.5;
|
||||
|
||||
return (
|
||||
latitude >= MIN_LAT &&
|
||||
latitude <= MAX_LAT &&
|
||||
longitude >= MIN_LNG &&
|
||||
longitude <= MAX_LNG
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find reports within a bounding box
|
||||
* @param minLat - Minimum latitude
|
||||
* @param maxLat - Maximum latitude
|
||||
* @param minLng - Minimum longitude
|
||||
* @param maxLng - Maximum longitude
|
||||
* @returns Array of reports within the bounding box
|
||||
*/
|
||||
export async function findReportsInBounds(
|
||||
minLat: number,
|
||||
maxLat: number,
|
||||
minLng: number,
|
||||
maxLng: number
|
||||
) {
|
||||
const results = await prisma.$queryRaw<Array<{
|
||||
id: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
litter_type: string;
|
||||
quantity: string;
|
||||
created_at: Date;
|
||||
}>>`
|
||||
SELECT
|
||||
id,
|
||||
latitude,
|
||||
longitude,
|
||||
litter_type,
|
||||
quantity,
|
||||
created_at
|
||||
FROM reports
|
||||
WHERE latitude BETWEEN ${minLat} AND ${maxLat}
|
||||
AND longitude BETWEEN ${minLng} AND ${maxLng}
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a report with spatial data
|
||||
* @param data - Report data including coordinates
|
||||
* @returns Created report
|
||||
*/
|
||||
export async function createReportWithLocation(data: {
|
||||
userId: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
locationSource: string;
|
||||
litterType: string;
|
||||
quantity: string;
|
||||
photoUrls: string[];
|
||||
photoTimestamp?: Date;
|
||||
description?: string;
|
||||
}) {
|
||||
// First create the report without the location POINT
|
||||
const report = await prisma.$executeRaw`
|
||||
INSERT INTO reports (
|
||||
id, user_id, latitude, longitude, location, location_source,
|
||||
litter_type, quantity, photo_urls, photo_timestamp, description
|
||||
) VALUES (
|
||||
UUID(),
|
||||
${data.userId},
|
||||
${data.latitude},
|
||||
${data.longitude},
|
||||
ST_GeomFromText(${`POINT(${data.longitude} ${data.latitude})`}, 4326),
|
||||
${data.locationSource},
|
||||
${data.litterType},
|
||||
${data.quantity},
|
||||
${JSON.stringify(data.photoUrls)},
|
||||
${data.photoTimestamp || null},
|
||||
${data.description || null}
|
||||
)
|
||||
`;
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update hotspots table with calculated data
|
||||
* Should be run periodically (e.g., every 15 minutes)
|
||||
*/
|
||||
export async function updateHotspotsTable() {
|
||||
// Clear old hotspots
|
||||
await prisma.$executeRaw`DELETE FROM hotspots WHERE calculated_at < DATE_SUB(NOW(), INTERVAL 1 HOUR)`;
|
||||
|
||||
// Calculate and insert new hotspots
|
||||
const hotspots = await calculateHotspots();
|
||||
|
||||
for (const hotspot of hotspots) {
|
||||
const severityScore = hotspot.report_count * hotspot.avg_severity;
|
||||
|
||||
await prisma.$executeRaw`
|
||||
INSERT INTO hotspots (
|
||||
id, latitude, longitude, location, radius,
|
||||
report_count, severity_score, last_report_date
|
||||
) VALUES (
|
||||
UUID(),
|
||||
${hotspot.center_lat},
|
||||
${hotspot.center_lng},
|
||||
ST_GeomFromText(${`POINT(${hotspot.center_lng} ${hotspot.center_lat})`}, 4326),
|
||||
500,
|
||||
${hotspot.report_count},
|
||||
${severityScore},
|
||||
${hotspot.last_report_date}
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
return hotspots.length;
|
||||
}
|
||||
|
||||
export default {
|
||||
createPoint,
|
||||
findReportsNearby,
|
||||
findEventsNearby,
|
||||
calculateHotspots,
|
||||
calculateDistance,
|
||||
isWithinIreland,
|
||||
findReportsInBounds,
|
||||
createReportWithLocation,
|
||||
updateHotspotsTable,
|
||||
};
|
||||
Reference in New Issue
Block a user