import { PrismaClient, crime_rates, crime_status, crimes, } from '@prisma/client'; import { generateId, generateIdWithDbCounter } from '../../app/_utils/common'; import { createClient } from '../../app/_utils/supabase/client'; import * as fs from 'fs'; import * as path from 'path'; import csv from 'csv-parser'; type ICreateLocations = { id: string; created_at: Date; updated_at: Date; district_id: string; event_id: string; address: string; type: string; latitude: number; longitude: number; land_area: number; location: string; }; export class CrimeIncidentsSeeder { private crimeMonthlyData: Map< string, { number_of_crime: number; crime_cleared: number } > = new Map(); constructor( private prisma: PrismaClient, private supabase = createClient() ) {} private async loadCrimeMonthlyData(): Promise { const csvFilePath = path.resolve( __dirname, '../data/excels/crimes/crime_monthly.csv' ); return new Promise((resolve, reject) => { fs.createReadStream(csvFilePath) .pipe(csv()) .on('data', (row) => { const key = `${row.district_id}-${row.month_num}-${row.year}`; this.crimeMonthlyData.set(key, { number_of_crime: parseInt(row.number_of_crime), crime_cleared: parseInt(row.crime_cleared), }); }) .on('end', () => { console.log( `Loaded ${this.crimeMonthlyData.size} crime monthly records.` ); resolve(); }) .on('error', (error) => { console.error('Error loading crime monthly data:', error); reject(error); }); }); } /** * Generates well-distributed points within a district's area with geographical constraints * @param centerLat - The center latitude of the district * @param centerLng - The center longitude of the district * @param landArea - Land area in square km * @param numPoints - Number of points to generate * @param districtId - ID of the district for special handling * @param districtName - Name of the district for constraints * @returns Array of {latitude, longitude, radius} points */ private generateDistributedPoints( centerLat: number, centerLng: number, landArea: number, numPoints: number, districtId: string, districtName: string ): Array<{ latitude: number; longitude: number; radius: number }> { const points = []; // Calculate a reasonable radius based on land area const areaFactor = Math.sqrt(landArea) / 100; const baseRadius = Math.max(0.01, Math.min(0.05, areaFactor * 0.03)); // Create a grid-based distribution for better coverage const gridSize = Math.ceil(Math.sqrt(numPoints * 1.5)); // Define district bounds with geographical constraints // Standard calculation for general districts let estimatedDistrictRadius = Math.sqrt(landArea / Math.PI) / 111; // District-specific adjustments to avoid generating points in the ocean const southernCoastalDistricts = [ 'puger', 'tempurejo', 'ambulu', 'gumukmas', 'kencong', 'wuluhan', 'kencong', ]; const isCoastalDistrict = southernCoastalDistricts.some((district) => districtName.toLowerCase().includes(district) ); // Default bounds let bounds = { minLat: centerLat - estimatedDistrictRadius, maxLat: centerLat + estimatedDistrictRadius, minLng: centerLng - estimatedDistrictRadius, maxLng: centerLng + estimatedDistrictRadius, }; // Apply special constraints for coastal districts if (isCoastalDistrict) { // Shift points northward for southern coastal districts to avoid ocean if ( districtName.toLowerCase().includes('puger') || districtName.toLowerCase().includes('tempurejo') ) { // For Puger and Tempurejo, shift more aggressively northward bounds = { minLat: centerLat, // Don't go south of the center maxLat: centerLat + estimatedDistrictRadius * 1.5, // Extend more to the north minLng: centerLng - estimatedDistrictRadius * 0.8, maxLng: centerLng + estimatedDistrictRadius * 0.8, }; } else { // For other coastal districts, shift moderately northward bounds = { minLat: centerLat - estimatedDistrictRadius * 0.5, // Less southward maxLat: centerLat + estimatedDistrictRadius * 1.2, // More northward minLng: centerLng - estimatedDistrictRadius, maxLng: centerLng + estimatedDistrictRadius, }; } } const latStep = (bounds.maxLat - bounds.minLat) / gridSize; const lngStep = (bounds.maxLng - bounds.minLng) / gridSize; // Generate points in each grid cell with some randomness let totalPoints = 0; for (let i = 0; i < gridSize && totalPoints < numPoints; i++) { for (let j = 0; j < gridSize && totalPoints < numPoints; j++) { // Base position within the grid cell with randomness const cellLat = bounds.minLat + (i + 0.2 + Math.random() * 0.6) * latStep; const cellLng = bounds.minLng + (j + 0.2 + Math.random() * 0.6) * lngStep; // Distance from center (for radius reference) const latDiff = cellLat - centerLat; const lngDiff = cellLng - centerLng; const distance = Math.sqrt(latDiff * latDiff + lngDiff * lngDiff); // Add some randomness to avoid perfect grid pattern const jitter = baseRadius * 0.2; const latitude = cellLat + (Math.random() * 2 - 1) * jitter; const longitude = cellLng + (Math.random() * 2 - 1) * jitter; // Ensure the point is within district boundaries // Simple check to ensure points don't stray too far from center if (distance <= estimatedDistrictRadius * 1.2) { points.push({ latitude, longitude, radius: distance * 111000, // Convert to meters (approx) }); totalPoints++; } } } // If we still need more points, add some with tighter constraints while (points.length < numPoints) { // For coastal districts, use more controlled distribution let latitude, longitude; if (isCoastalDistrict) { // Generate points with northward bias for coastal districts const northBias = Math.random() * 0.7 + 0.3; // 0.3 to 1.0, favoring north latitude = centerLat + northBias * estimatedDistrictRadius * 0.8; longitude = centerLng + (Math.random() * 2 - 1) * estimatedDistrictRadius * 0.8; } else { // Standard distribution for non-coastal districts latitude = centerLat + (Math.random() * 2 - 1) * estimatedDistrictRadius * 0.8; longitude = centerLng + (Math.random() * 2 - 1) * estimatedDistrictRadius * 0.8; } const latDiff = latitude - centerLat; const lngDiff = longitude - centerLng; const distance = Math.sqrt(latDiff * latDiff + lngDiff * lngDiff); points.push({ latitude, longitude, radius: distance * 111000, // Convert to meters (approx) }); } return points; } // Helper for chunked insertion private async chunkedInsertIncidents(data: any[], chunkSize: number = 200) { for (let i = 0; i < data.length; i += chunkSize) { const chunk = data.slice(i, i + chunkSize); await this.prisma.crime_incidents.createMany({ data: chunk, skipDuplicates: true, }); } } // Helper for chunked Supabase insert private async chunkedInsertLocations( locations: any[], chunkSize: number = 200 ) { for (let i = 0; i < locations.length; i += chunkSize) { const chunk = locations.slice(i, i + chunkSize); let { error } = await this.supabase .from('locations') .insert(chunk) .select(); if (error) { throw error; } } } async run(): Promise { console.log('🌱 Seeding crime incidents data...'); try { // Load crime monthly data first await this.loadCrimeMonthlyData(); // Check if crime incidents already exist const existingIncidents = await this.prisma.crime_incidents.findFirst(); if (existingIncidents) { console.log('Crime incidents data already exists, skipping import.'); return; } // Get all monthly crime records (exclude yearly summaries) const crimeRecords = await this.prisma.crimes.findMany({ where: { month: { not: null }, // Only process records with incidents (number_of_crime > 0) number_of_crime: { gt: 0 }, }, orderBy: [{ district_id: 'asc' }, { year: 'asc' }, { month: 'asc' }], }); console.log( `Found ${crimeRecords.length} monthly crime records with incidents to process.` ); // Get all crime categories for random assignment const crimeCategories = await this.prisma.crime_categories.findMany(); if (crimeCategories.length === 0) { console.error( 'No crime categories found, please seed crime categories first.' ); return; } // Process each crime record let totalIncidentsCreated = 0; for (const crimeRecord of crimeRecords) { const incidents = await this.createIncidentsForCrime( crimeRecord, crimeCategories ); totalIncidentsCreated += incidents.length; // Log progress every 50 crime records if (totalIncidentsCreated % 50 === 0) { console.log(`Created ${totalIncidentsCreated} incidents so far...`); } } console.log( `✅ Successfully created ${totalIncidentsCreated} crime incidents.` ); } catch (error) { console.error('❌ Error seeding crime incidents:', error); throw error; } } private async createIncidentsForCrime( crime: crimes, categories: any[] ): Promise { const incidentsCreated = []; // Get district information const district = await this.prisma.districts.findUnique({ where: { id: crime.district_id }, include: { cities: true }, }); if (!district) { console.error(`District ${crime.district_id} not found, skipping.`); return []; } const geo = await this.prisma.geographics.findFirst({ where: { district_id: district.id, year: crime.year, }, select: { latitude: true, longitude: true, land_area: true, }, }); if (!geo) { console.error( `Geographic data for district ${district.id} in year ${crime.year} not found, skipping.` ); return []; } // Use the actual number of crimes instead of a random count const numLocations = crime.number_of_crime; // Update the call to the function in createIncidentsForCrime method: const locationPool = this.generateDistributedPoints( geo.latitude, geo.longitude, geo.land_area || 100, // Default to 100 km² if not available numLocations, district.id, district.name ); // List of common street names in Jember with more variety const jemberStreets = [ 'Jalan Pahlawan', 'Jalan Merdeka', 'Jalan Cendrawasih', 'Jalan Srikandi', 'Jalan Sumbermujur', 'Jalan Taman Siswa', 'Jalan Pantai', 'Jalan Raya Sumberbaru', 'Jalan Abdurrahman Saleh', 'Jalan Mastrip', 'Jalan PB Sudirman', 'Jalan Kalimantan', 'Jalan Sumatra', 'Jalan Jawa', 'Jalan Gajah Mada', 'Jalan Letjen Suprapto', 'Jalan Hayam Wuruk', 'Jalan Trunojoyo', 'Jalan Imam Bonjol', 'Jalan Diponegoro', 'Jalan Ahmad Yani', 'Jalan Kartini', 'Jalan Gatot Subroto', ]; // More varied place types const placeTypes = [ 'Perumahan', 'Apartemen', 'Komplek', 'Pasar', 'Toko', 'Terminal', 'Stasiun', 'Kampus', 'Sekolah', 'Perkantoran', 'Pertokoan', 'Pusat Perbelanjaan', 'Taman', 'Alun-alun', 'Simpang', 'Pertigaan', 'Perempatan', ]; const user = await this.prisma.users.findFirst({ where: { email: 'sigapcompany@gmail.com', }, select: { id: true, }, }); if (!user) { console.error(`User not found, skipping.`); return []; } const event = await this.prisma.events.findFirst({ where: { user_id: user.id, }, }); if (!event) { console.error(`Event not found, skipping.`); return []; } // Get crime_cleared data from the loaded CSV const key = `${crime.district_id}-${crime.month}-${crime.year}`; const crimeMonthlyInfo = this.crimeMonthlyData.get(key); // Default values if not found in CSV let crimesCleared = 0; if (crimeMonthlyInfo) { crimesCleared = crimeMonthlyInfo.crime_cleared; // Safety check to ensure crime_cleared doesn't exceed number_of_crime if (crimesCleared > crime.number_of_crime) { crimesCleared = crime.number_of_crime; } } else { console.warn( `No crime monthly data found for ${key}, using default values.` ); } const incidentsToCreate = []; const locationsToCreate = []; // Create incidents based on the number_of_crime value for (let i = 0; i < crime.number_of_crime; i++) { // Select random category const randomCategory = categories[Math.floor(Math.random() * categories.length)]; // Calculate a date within the crime's month const year = crime.year; if (!year) { console.error( `Year is not defined for crime record ${crime.id}, skipping.` ); return []; } const month = (crime.month as number) - 1; // JavaScript months are 0-indexed const maxDay = new Date(year, month + 1, 0).getDate(); // Get last day of month const day = Math.floor(Math.random() * maxDay) + 1; const hour = Math.floor(Math.random() * 24); const minute = Math.floor(Math.random() * 60); const timestamp = new Date(year, month, day, hour, minute); // Select a random location from our pool const randomLocationIndex = Math.floor( Math.random() * locationPool.length ); const selectedLocation = locationPool[randomLocationIndex]; // Generate varied address details const streetName = jemberStreets[Math.floor(Math.random() * jemberStreets.length)]; const buildingNumber = Math.floor(Math.random() * 200) + 1; const placeType = placeTypes[Math.floor(Math.random() * placeTypes.length)]; // Create more varied addresses let randomAddress; const addressType = Math.floor(Math.random() * 3); switch (addressType) { case 0: randomAddress = `${streetName} No. ${buildingNumber}, ${district.name}, Jember`; break; case 1: randomAddress = `${placeType} ${district.name}, ${streetName}, Jember`; break; case 2: randomAddress = `${streetName} Blok ${String.fromCharCode(65 + Math.floor(Math.random() * 26))}-${Math.floor(Math.random() * 20) + 1}, ${district.name}, Jember`; break; } const locationData: Partial = { district_id: district.id, event_id: event.id, address: randomAddress, type: 'crime', latitude: selectedLocation.latitude, longitude: selectedLocation.longitude, location: `POINT(${selectedLocation.longitude} ${selectedLocation.latitude})`, }; // Tambahkan ke array, bukan langsung create ke database locationsToCreate.push(locationData); const incidentId = await generateIdWithDbCounter( 'crime_incidents', { prefix: 'CI', segments: { codes: [district.city_id], sequentialDigits: 4, year, }, format: '{prefix}-{codes}-{sequence}-{year}', separator: '-', uniquenessStrategy: 'counter', }, /(\d{4})(?=-\d{4}$)/ // Pattern to extract the 4-digit counter ); // Determine status based on crime_cleared // If i < crimesCleared, this incident is resolved, otherwise unresolved const status = i < crimesCleared ? ('resolved' as crime_status) : ('unresolved' as crime_status); // More detailed location descriptions const locs = [ `di daerah ${district.name}`, `di sekitar ${district.name}`, `di wilayah ${district.name}`, `di jalan utama ${district.name}`, `di perumahan ${district.name}`, `di pasar ${district.name}`, `di perbatasan ${district.name}`, `di kawasan ${placeType.toLowerCase()} ${district.name}`, `di persimpangan jalan ${streetName}`, `di dekat ${placeType.toLowerCase()} ${district.name}`, `di belakang ${placeType.toLowerCase()} ${district.name}`, `di area ${streetName}`, `di sekitar ${streetName} ${district.name}`, `tidak jauh dari pusat ${district.name}`, `di pinggiran ${district.name}`, ]; const randomLocation = locs[Math.floor(Math.random() * locs.length)]; const descriptions = [ `Kasus ${randomCategory.name.toLowerCase()} ${randomAddress}`, `Laporan ${randomCategory.name.toLowerCase()} terjadi pada ${timestamp} ${randomLocation}`, `${randomCategory.name} dilaporkan ${randomLocation}`, `Insiden ${randomCategory.name.toLowerCase()} terjadi ${randomLocation}`, `Kejadian ${randomCategory.name.toLowerCase()} ${randomLocation}`, `${randomCategory.name} terdeteksi ${randomLocation} pada ${timestamp.toLocaleTimeString()}`, `Pelaporan ${randomCategory.name.toLowerCase()} di ${randomAddress}`, `Kasus ${randomCategory.name.toLowerCase()} terjadi di ${streetName}`, `${randomCategory.name} terjadi di dekat ${placeType.toLowerCase()} ${district.name}`, `Insiden ${randomCategory.name.toLowerCase()} dilaporkan warga setempat ${randomLocation}`, ]; const randomDescription = descriptions[Math.floor(Math.random() * descriptions.length)]; incidentsToCreate.push({ id: incidentId, crime_id: crime.id, crime_category_id: randomCategory.id, location_id: undefined as string | undefined, // This will be updated after locations are created description: randomDescription, victim_count: 0, status: status, timestamp: timestamp, }); } // Batch insert locations in chunks try { await this.chunkedInsertLocations(locationsToCreate); } catch (error) { console.error( `Error inserting into locations for district ${district.name} (${crime.year}):`, error ); return []; } // Fetch all created locations for this batch const createdLocations = await this.prisma.locations.findMany({ where: { event_id: event.id, district_id: district.id, address: { in: locationsToCreate .map((loc) => loc.address) .filter((address): address is string => address !== undefined), }, }, select: { id: true, address: true, }, }); // Map addresses to location IDs const addressToId = new Map(); for (const loc of createdLocations) { if (loc.address !== null) { addressToId.set(loc.address, loc.id); } } // Assign location_id to each incident for (let i = 0; i < incidentsToCreate.length; i++) { const address = locationsToCreate[i].address; if (typeof address === 'string') { incidentsToCreate[i].location_id = addressToId.get(address); } } // Batch insert incidents in chunks await this.chunkedInsertIncidents(incidentsToCreate); incidentsCreated.push(...incidentsToCreate); return incidentsCreated; } } // This allows the file to be executed standalone for testing if (require.main === module) { const testSeeder = async () => { const prisma = new PrismaClient(); const seeder = new CrimeIncidentsSeeder(prisma); try { await seeder.run(); } catch (e) { console.error('Error during seeding:', e); process.exit(1); } finally { await prisma.$disconnect(); } }; testSeeder(); }