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:
@@ -1,7 +1,55 @@
|
||||
/**
|
||||
* Database configuration and Prisma client singleton
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||
});
|
||||
// PrismaClient is attached to the `global` object in development to prevent
|
||||
// exhausting your database connection limit.
|
||||
const globalForPrisma = global as unknown as { prisma: PrismaClient };
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ||
|
||||
new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalForPrisma.prisma = prisma;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the database
|
||||
*/
|
||||
export async function connectDatabase() {
|
||||
try {
|
||||
await prisma.$connect();
|
||||
console.log('✅ Database connected successfully');
|
||||
} catch (error) {
|
||||
console.error('❌ Database connection failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from the database
|
||||
*/
|
||||
export async function disconnectDatabase() {
|
||||
await prisma.$disconnect();
|
||||
console.log('Database disconnected');
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check for database connection
|
||||
*/
|
||||
export async function checkDatabaseHealth(): Promise<boolean> {
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Database health check failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default prisma;
|
||||
|
||||
137
server/src/scripts/testDatabase.ts
Normal file
137
server/src/scripts/testDatabase.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Database connection and spatial support test script
|
||||
* Run with: npx tsx src/scripts/testDatabase.ts
|
||||
*/
|
||||
|
||||
import { prisma } from '../config/database';
|
||||
import { isWithinIreland } from '../utils/spatial';
|
||||
|
||||
async function testDatabaseConnection() {
|
||||
console.log('🔍 Testing database connection and spatial support...\n');
|
||||
|
||||
try {
|
||||
// Test 1: Basic connection
|
||||
console.log('1. Testing database connection...');
|
||||
await prisma.$queryRaw`SELECT 1 as test`;
|
||||
console.log(' ✅ Database connection successful\n');
|
||||
|
||||
// Test 2: Check MySQL version
|
||||
console.log('2. Checking MySQL version...');
|
||||
const version = await prisma.$queryRaw<Array<{ version: string }>>`SELECT VERSION() as version`;
|
||||
console.log(` ✅ MySQL version: ${version[0].version}\n`);
|
||||
|
||||
// Test 3: Verify spatial support
|
||||
console.log('3. Testing spatial support...');
|
||||
const spatialTest = await prisma.$queryRaw<Array<{ point: string; distance: number }>>`
|
||||
SELECT
|
||||
ST_AsText(ST_GeomFromText('POINT(-6.2603 53.3498)', 4326)) as point,
|
||||
ST_Distance_Sphere(
|
||||
ST_GeomFromText('POINT(-6.2603 53.3498)', 4326),
|
||||
ST_GeomFromText('POINT(-6.2603 53.3498)', 4326)
|
||||
) as distance
|
||||
`;
|
||||
console.log(` ✅ Spatial functions working: ${spatialTest[0].point}\n`);
|
||||
|
||||
// Test 4: Check tables exist
|
||||
console.log('4. Checking database tables...');
|
||||
const tables = await prisma.$queryRaw<Array<{ table_name: string }>>`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
ORDER BY table_name
|
||||
`;
|
||||
console.log(` ✅ Found ${tables.length} tables:`);
|
||||
tables.forEach(t => console.log(` - ${t.table_name}`));
|
||||
console.log('');
|
||||
|
||||
// Test 5: Check spatial indexes
|
||||
console.log('5. Checking spatial indexes...');
|
||||
const spatialIndexes = await prisma.$queryRaw<Array<{
|
||||
table_name: string;
|
||||
index_name: string;
|
||||
column_name: string;
|
||||
}>>`
|
||||
SELECT
|
||||
table_name,
|
||||
index_name,
|
||||
column_name
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND index_type = 'SPATIAL'
|
||||
ORDER BY table_name, index_name
|
||||
`;
|
||||
console.log(` ✅ Found ${spatialIndexes.length} spatial indexes:`);
|
||||
spatialIndexes.forEach(idx =>
|
||||
console.log(` - ${idx.table_name}.${idx.column_name} (${idx.index_name})`)
|
||||
);
|
||||
console.log('');
|
||||
|
||||
// Test 6: Check User table structure
|
||||
console.log('6. Checking User table for Clerk support...');
|
||||
const userColumns = await prisma.$queryRaw<Array<{
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
is_nullable: string;
|
||||
}>>`
|
||||
SELECT column_name, data_type, is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'users'
|
||||
ORDER BY ordinal_position
|
||||
`;
|
||||
const hasClerkId = userColumns.some(col => col.column_name === 'clerk_id');
|
||||
if (hasClerkId) {
|
||||
console.log(' ✅ User table has clerk_id column (Clerk authentication ready)');
|
||||
} else {
|
||||
console.log(' ⚠️ User table missing clerk_id column');
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// Test 7: Test coordinate validation
|
||||
console.log('7. Testing coordinate validation...');
|
||||
const dublinCoords = { lat: 53.3498, lng: -6.2603 };
|
||||
const londonCoords = { lat: 51.5074, lng: -0.1278 };
|
||||
|
||||
console.log(` Dublin (${dublinCoords.lat}, ${dublinCoords.lng}): ${
|
||||
isWithinIreland(dublinCoords.lat, dublinCoords.lng) ? '✅ Valid' : '❌ Invalid'
|
||||
}`);
|
||||
console.log(` London (${londonCoords.lat}, ${londonCoords.lng}): ${
|
||||
isWithinIreland(londonCoords.lat, londonCoords.lng) ? '❌ Should be invalid' : '✅ Correctly rejected'
|
||||
}`);
|
||||
console.log('');
|
||||
|
||||
// Test 8: Count existing records
|
||||
console.log('8. Checking existing data...');
|
||||
const userCount = await prisma.user.count();
|
||||
const reportCount = await prisma.report.count();
|
||||
const eventCount = await prisma.event.count();
|
||||
console.log(` Users: ${userCount}`);
|
||||
console.log(` Reports: ${reportCount}`);
|
||||
console.log(` Events: ${eventCount}`);
|
||||
console.log('');
|
||||
|
||||
console.log('✅ All database tests passed!\n');
|
||||
console.log('Database is ready for use with:');
|
||||
console.log(' - MySQL 8.0+ with spatial support');
|
||||
console.log(' - Clerk authentication (clerk_id field)');
|
||||
console.log(' - Spatial indexes on location columns');
|
||||
console.log(' - All required tables and relationships');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Database test failed:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// Run the test
|
||||
testDatabaseConnection()
|
||||
.then(() => {
|
||||
console.log('\n✅ Test completed successfully');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('\n❌ Test failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
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