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:
2025-11-16 18:46:00 +00:00
parent 82a365f357
commit fc2fff5cb1
11 changed files with 1032 additions and 5 deletions

315
server/src/utils/spatial.ts Normal file
View 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,
};