diff --git a/sigap-website/.vscode/extensions.json b/sigap-website/.vscode/extensions.json new file mode 100644 index 0000000..74baffc --- /dev/null +++ b/sigap-website/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["denoland.vscode-deno"] +} diff --git a/sigap-website/.vscode/settings.json b/sigap-website/.vscode/settings.json index 8bb69fd..4ff4a86 100644 --- a/sigap-website/.vscode/settings.json +++ b/sigap-website/.vscode/settings.json @@ -1,3 +1,25 @@ { + "[typescript]": { + "editor.defaultFormatter": "denoland.vscode-deno" + }, + "deno.enablePaths": [ + "supabase/functions" + ], + "deno.lint": true, + "deno.unstable": [ + "bare-node-builtins", + "byonm", + "sloppy-imports", + "unsafe-proto", + "webgpu", + "broadcast-channel", + "worker-options", + "cron", + "kv", + "ffi", + "fs", + "http", + "net" + ], "files.autoSave": "off" } diff --git a/sigap-website/prisma/schema.prisma b/sigap-website/prisma/schema.prisma index f9eaa1a..5ee324c 100644 --- a/sigap-website/prisma/schema.prisma +++ b/sigap-website/prisma/schema.prisma @@ -263,7 +263,6 @@ model incident_logs { model units { code_unit String @id @unique @db.VarChar(20) district_id String @db.VarChar(20) - city_id String @db.VarChar(20) name String @db.VarChar(100) description String? type unit_type @@ -275,9 +274,10 @@ model units { longitude Float location Unsupported("geography") phone String? + city_id String @db.VarChar(20) unit_statistics unit_statistics[] - districts districts @relation(fields: [district_id], references: [id], onDelete: Cascade, onUpdate: NoAction) cities cities @relation(fields: [city_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + districts districts @relation(fields: [district_id], references: [id], onDelete: Cascade, onUpdate: NoAction) @@index([name], map: "idx_units_name") @@index([type], map: "idx_units_type") diff --git a/sigap-website/prisma/seeds/crime-category.ts b/sigap-website/prisma/seeds/crime-category.ts index 3cd2da0..e20a55c 100644 --- a/sigap-website/prisma/seeds/crime-category.ts +++ b/sigap-website/prisma/seeds/crime-category.ts @@ -35,6 +35,10 @@ export class CrimeCategoriesSeeder { const sheet = workbook.Sheets[workbook.SheetNames[0]]; const data = XLSX.utils.sheet_to_json(sheet) as ICrimeCategory[]; + // Prepare array for batch insertion + const categoriesToCreate = []; + + // Generate IDs and prepare data for batch insertion for (const category of crimeCategoriesData) { const newId = await generateIdWithDbCounter('crime_categories', { prefix: 'CC', @@ -46,32 +50,41 @@ export class CrimeCategoriesSeeder { uniquenessStrategy: 'counter', }); - await this.prisma.crime_categories.create({ - data: { - id: newId.trim(), - name: category.name, - description: category.description, - }, + categoriesToCreate.push({ + id: newId.trim(), + name: category.name, + description: category.description, }); - - console.log(`Seeding crime category: ${category.name}`); } - // Update existing records - for (const row of data) { - const id = row['id'].trim(); - const name = row['name'].trim(); - const type = row['type'].trim(); + // Batch create categories + await this.prisma.crime_categories.createMany({ + data: categoriesToCreate, + skipDuplicates: true, + }); - await this.prisma.crime_categories.updateMany({ - where: { id }, - data: { - type, - }, - }); + console.log(`Batch created ${categoriesToCreate.length} crime categories.`); - console.log(`Updating crime category: ${name} to type ${type}`); - } + // Prepare data for batch update + const categoriesToUpdate = data.map((row) => ({ + id: row['id'].trim(), + type: row['type'].trim(), + name: row['name'].trim(), + })); + + // Batch update is not directly supported by Prisma, so we'll use Promise.all with individual updates + await Promise.all( + categoriesToUpdate.map((category) => + this.prisma.crime_categories.updateMany({ + where: { id: category.id }, + data: { type: category.type }, + }) + ) + ); + + console.log( + `Updated types for ${categoriesToUpdate.length} crime categories.` + ); console.log(`✅ ${crimeCategoriesData.length} crime categories seeded`); } diff --git a/sigap-website/prisma/seeds/crime-incidents.ts b/sigap-website/prisma/seeds/crime-incidents.ts index 13329d8..3e1cd53 100644 --- a/sigap-website/prisma/seeds/crime-incidents.ts +++ b/sigap-website/prisma/seeds/crime-incidents.ts @@ -212,6 +212,34 @@ export class CrimeIncidentsSeeder { 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...'); @@ -372,7 +400,7 @@ export class CrimeIncidentsSeeder { const user = await this.prisma.users.findFirst({ where: { - email: 'admin@gmail.com', + email: 'sigapcompany@gmail.com', }, select: { id: true, @@ -414,6 +442,9 @@ export class CrimeIncidentsSeeder { ); } + 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 @@ -476,39 +507,9 @@ export class CrimeIncidentsSeeder { location: `POINT(${selectedLocation.longitude} ${selectedLocation.latitude})`, }; - let { data: newLocation, error } = await this.supabase - .from('locations') - .insert([locationData]) - .select(); + // Tambahkan ke array, bukan langsung create ke database + locationsToCreate.push(locationData); - if (error) { - console.error( - `Error inserting into locations for district ${district.name} (${crime.year}):`, - error - ); - return []; - } - - const location = await this.prisma.locations.findFirst({ - where: { - event_id: event.id, - district_id: district.id, - address: randomAddress, - }, - select: { - id: true, - address: true, - }, - }); - - if (!location) { - console.error( - `Location not found for district ${district.name} (${crime.year}), skipping.` - ); - return []; - } - - // Generate a unique ID for the incident const incidentId = await generateIdWithDbCounter( 'crime_incidents', { @@ -527,7 +528,10 @@ export class CrimeIncidentsSeeder { // Determine status based on crime_cleared // If i < crimesCleared, this incident is resolved, otherwise unresolved - const status = i < crimesCleared ? 'resolved' : 'unresolved'; + const status = + i < crimesCleared + ? ('resolved' as crime_status) + : ('unresolved' as crime_status); // More detailed location descriptions const locs = [ @@ -551,13 +555,13 @@ export class CrimeIncidentsSeeder { const randomLocation = locs[Math.floor(Math.random() * locs.length)]; const descriptions = [ - `Kasus ${randomCategory.name.toLowerCase()} ${location.address}`, + `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 ${location.address}`, + `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}`, @@ -566,23 +570,67 @@ export class CrimeIncidentsSeeder { const randomDescription = descriptions[Math.floor(Math.random() * descriptions.length)]; - // Create the crime incident - const incident = await this.prisma.crime_incidents.create({ - data: { - id: incidentId, - crime_id: crime.id, - crime_category_id: randomCategory.id, - location_id: location.id, - description: randomDescription, - victim_count: 0, - status: status, - timestamp: timestamp, - }, + 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, }); - - incidentsCreated.push(incident); } + // 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; } } diff --git a/sigap-website/prisma/seeds/crimes.ts b/sigap-website/prisma/seeds/crimes.ts index 4545697..7803c0a 100644 --- a/sigap-website/prisma/seeds/crimes.ts +++ b/sigap-website/prisma/seeds/crimes.ts @@ -10,6 +10,22 @@ import path from 'path'; import { parse } from 'csv-parse/sync'; import { generateId, generateIdWithDbCounter } from '../../app/_utils/common'; +interface ICreateUser { + id: string; + email: string; + roles_id: string; + confirmed_at: Date | null; + email_confirmed_at: Date | null; + last_sign_in_at: Date | null; + phone: string | null; + updated_at: Date | null; + created_at: Date | null; + app_metadata: any; + invited_at: Date | null; + user_metadata: any; + is_anonymous: boolean; +} + export class CrimesSeeder { constructor(private prisma: PrismaClient) {} @@ -22,6 +38,10 @@ export class CrimesSeeder { // Create test user const user = await this.createUsers(); + if (!user) { + throw new Error('Failed to create user'); + } + // Create 5 events const events = await this.createEvents(user); @@ -47,7 +67,7 @@ export class CrimesSeeder { private async createUsers() { // Check if test users already exist const existingUser = await this.prisma.users.findFirst({ - where: { email: 'admin@sigap.id' }, + where: { email: 'sigapcompany@gmail.com' }, }); if (existingUser) { @@ -69,28 +89,35 @@ export class CrimesSeeder { }); } - // Create test users - const user = await this.prisma.users.create({ + // Create test user directly with Prisma (no Supabase) + const newUser = await this.prisma.users.create({ data: { - email: `admin@gmail.com`, + email: 'sigapcompany@gmail.com', roles_id: roleId.id, confirmed_at: new Date(), email_confirmed_at: new Date(), + last_sign_in_at: null, + phone: null, + updated_at: new Date(), + created_at: new Date(), + app_metadata: {}, + invited_at: null, + user_metadata: {}, is_anonymous: false, profile: { create: { first_name: 'Admin', last_name: 'Sigap', - username: 'admin', + username: 'adminsigap', }, }, }, }); - return user; + return newUser; } - private async createEvents(user: users) { + private async createEvents(user: ICreateUser) { // Check if events already exist const existingEvent = await this.prisma.events.findFirst({ where: { @@ -114,7 +141,7 @@ export class CrimesSeeder { return event; } - private async createSessions(user: users, events: events) { + private async createSessions(user: ICreateUser, events: events) { // Check if sessions already exist const existingSession = await this.prisma.sessions.findFirst(); @@ -134,6 +161,16 @@ export class CrimesSeeder { return newSession; } + // Helper function for chunked insertion (with default chunk size 500) + private async chunkedCreateMany(data: any[], chunkSize: number = 500) { + for (let i = 0; i < data.length; i += chunkSize) { + const chunk = data.slice(i, i + chunkSize); + await this.prisma.crimes.createMany({ + data: chunk, + }); + } + } + private async importMonthlyCrimeData() { console.log('Importing monthly crime data...'); @@ -160,7 +197,10 @@ export class CrimesSeeder { // Store unique district IDs to avoid duplicates const processedDistricts = new Set(); - // Import records + // Prepare batch data + const crimesData = []; + + // Process records for (const record of records) { const crimeRate = record.level.toLowerCase() as crime_rates; @@ -193,25 +233,26 @@ export class CrimesSeeder { /(\d{4})(?=-\d{4}$)/ // Pattern to extract the 4-digit counter ); - console.log('Creating crime ID:', crimeId); + // console.log('Creating crime ID:', crimeId); - await this.prisma.crimes.create({ - data: { - id: crimeId, - district_id: record.district_id, - level: crimeRate, - method: record.method || null, - month: parseInt(record.month_num), - year: parseInt(record.year), - number_of_crime: parseInt(record.number_of_crime), - score: parseFloat(record.score), - }, + crimesData.push({ + id: crimeId, + district_id: record.district_id, + level: crimeRate, + method: record.method || null, + month: parseInt(record.month_num), + year: parseInt(record.year), + number_of_crime: parseInt(record.number_of_crime), + score: parseFloat(record.score), }); // Keep track of unique districts for later creation of crime incidents processedDistricts.add(record.district_id); } + // Batch create all crimes in chunks + await this.chunkedCreateMany(crimesData); + console.log(`Imported ${records.length} monthly crime records.`); } @@ -241,7 +282,10 @@ export class CrimesSeeder { skip_empty_lines: true, }); - // Import records + // Prepare batch data + const crimesData = []; + + // Process records for (const record of records) { const crimeRate = record.level.toLowerCase() as crime_rates; const year = parseInt(record.year); @@ -261,22 +305,6 @@ export class CrimesSeeder { continue; } - // Create a unique ID for yearly crime data - // const crimeId = await generateId({ - // prefix: 'CR', - // segments: { - // codes: [city.id], - // sequentialDigits: 4, - // year, - // }, - // format: '{prefix}-{codes}-{sequence}-{year}', - // separator: '-', - // randomSequence: false, - // uniquenessStrategy: 'counter', - // storage: 'database', - // tableName: 'crimes', - // }); - const crimeId = await generateIdWithDbCounter( 'crimes', { @@ -293,20 +321,21 @@ export class CrimesSeeder { /(\d{4})(?=-\d{4}$)/ // Pattern to extract the 4-digit counter ); - await this.prisma.crimes.create({ - data: { - id: crimeId, - district_id: record.district_id, - level: crimeRate, - method: record.method || 'kmeans', - month: null, - year: year, - number_of_crime: parseInt(record.number_of_crime), - score: parseInt(record.score), - }, + crimesData.push({ + id: crimeId, + district_id: record.district_id, + level: crimeRate, + method: record.method || 'kmeans', + month: null, + year: year, + number_of_crime: parseInt(record.number_of_crime), + score: parseInt(record.score), }); } + // Batch create all yearly crimes in chunks + await this.chunkedCreateMany(crimesData); + console.log(`Imported ${records.length} yearly crime records.`); } @@ -336,6 +365,9 @@ export class CrimesSeeder { skip_empty_lines: true, }); + // Prepare batch data + const crimesData = []; + for (const record of records) { const crimeRate = record.level.toLowerCase() as crime_rates; const districtId = record.district_id; @@ -371,20 +403,21 @@ export class CrimesSeeder { /(\d{4})$/ // Pattern to extract the 4-digit counter at the end ); - await this.prisma.crimes.create({ - data: { - id: crimeId, - district_id: districtId, - level: crimeRate, - method: 'kmeans', - month: null, - year: null, - number_of_crime: parseInt(record.crime_total), - score: parseFloat(record.avg_score), - }, + crimesData.push({ + id: crimeId, + district_id: districtId, + level: crimeRate, + method: 'kmeans', + month: null, + year: null, + number_of_crime: parseInt(record.crime_total), + score: parseFloat(record.avg_score), }); } + // Batch create all all-year summaries in chunks + await this.chunkedCreateMany(crimesData); + console.log(`Imported ${records.length} all-year crime summaries.`); } } diff --git a/sigap-website/prisma/seeds/demographic.ts b/sigap-website/prisma/seeds/demographic.ts index 6b7868e..e267418 100644 --- a/sigap-website/prisma/seeds/demographic.ts +++ b/sigap-website/prisma/seeds/demographic.ts @@ -14,7 +14,6 @@ export class DemographicsSeeder { await this.prisma.demographics.deleteMany({}); const districts = await this.prisma.districts.findMany(); - // await this.prisma.$executeRaw`TRUNCATE TABLE "demographics" CASCADE`; // Load Excel const filePath = path.join( @@ -27,6 +26,12 @@ export class DemographicsSeeder { let counter = 0; + // Get all district land areas in a single query at the beginning + const districtLandAreas = await this.getAllDistrictLandAreas(); + + // Collect demographic data to be inserted in batch + const demographicsToInsert = []; + for (const row of data) { const districtName = String(row['Kecamatan']).trim(); const year = Number(row['Tahun']); @@ -42,28 +47,29 @@ export class DemographicsSeeder { continue; } - const districtLandArea = await this.getDistrictLandArea(district.id); + const districtLandArea = districtLandAreas[district.id] || 0; const populationDensity = districtLandArea > 0 ? population / districtLandArea : 0; - await this.prisma.demographics.create({ - data: { - district_id: district.id, - year, - population, - population_density: populationDensity, - number_of_unemployed: unemployed, - }, + demographicsToInsert.push({ + district_id: district.id, + year, + population, + population_density: populationDensity, + number_of_unemployed: unemployed, }); counter++; - - console.log( - `Seeding demographic data for district: ${districtName}, year: ${year}` - ); } + // Insert all demographic data at once + await this.prisma.demographics.createMany({ + data: demographicsToInsert, + skipDuplicates: true, + }); + + console.log(`✅ ${counter} demographic records prepared for batch insertion`); console.log(`✅ ${counter} demographic records seeded from Excel`); } @@ -71,20 +77,21 @@ export class DemographicsSeeder { return Math.random() * (max - min) + min; } - private async getDistrictLandArea(districtId: string): Promise { - const geo = await this.prisma.geographics.findFirst({ - where: { district_id: districtId }, + // Get all district land areas at once to avoid multiple database queries + private async getAllDistrictLandAreas(): Promise> { + const geoData = await this.prisma.geographics.findMany({ select: { + district_id: true, land_area: true, - }, + } }); - if (!geo) { - console.error(`⚠️ Land area not found for district ID: ${districtId}`); - return 0; - } + const landAreas: Record = {}; + geoData.forEach(geo => { + landAreas[geo.district_id] = geo.land_area || 0; + }); - return geo.land_area || 0; + return landAreas; } private async getCityLandArea(): Promise { diff --git a/sigap-website/prisma/seeds/geographic.ts b/sigap-website/prisma/seeds/geographic.ts index b996293..cf75e37 100644 --- a/sigap-website/prisma/seeds/geographic.ts +++ b/sigap-website/prisma/seeds/geographic.ts @@ -29,6 +29,7 @@ interface DistrictAreaData { export class GeoJSONSeeder { private mapboxToken: string; private areaData: DistrictAreaData = {}; + private BATCH_SIZE = 20; // Set a smaller batch size to prevent timeout constructor( private prisma: PrismaClient, @@ -102,6 +103,61 @@ export class GeoJSONSeeder { return 0; } + /** + * Split array into chunks of the specified size + */ + private chunkArray(array: T[], chunkSize: number): T[][] { + const chunks = []; + for (let i = 0; i < array.length; i += chunkSize) { + chunks.push(array.slice(i, i + chunkSize)); + } + return chunks; + } + + /** + * Insert data in smaller batches to avoid timeout + */ + private async insertInBatches(data: any[]): Promise { + const batches = this.chunkArray(data, this.BATCH_SIZE); + + console.log( + `Splitting ${data.length} records into ${batches.length} batches of max ${this.BATCH_SIZE} records` + ); + + for (let i = 0; i < batches.length; i++) { + const batch = batches[i]; + console.log( + `Processing batch ${i + 1}/${batches.length} (${batch.length} records)` + ); + + const { error } = await this.supabase + .from('geographics') + .insert(batch) + .select(); + + if (error) { + console.error(`Error inserting batch ${i + 1}:`, error); + // Optionally reduce batch size and retry for this specific batch + if (batch.length > 5) { + console.log(`Retrying batch ${i + 1} with smaller chunks...`); + await this.insertInBatches(batch); // Recursive retry with automatic smaller chunks + } else { + console.error( + `Failed to insert items even with small batch size:`, + batch + ); + } + } else { + console.log( + `Successfully inserted batch ${i + 1} (${batch.length} records)` + ); + } + + // Add a small delay between batches to reduce database load + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } + async run(): Promise { console.log('Seeding GeoJSON data...'); @@ -124,7 +180,6 @@ export class GeoJSONSeeder { for (const feature of regencyGeoJson.features) { const properties = feature.properties; - const geometry = feature.geometry; // Cleanup code const regencyCode = properties.kode_kk.replace(/\./g, '').trim(); @@ -137,41 +192,67 @@ export class GeoJSONSeeder { }, }); - // 2. Loop Semua District di GeoJSON - for (const feature of districtGeoJson.features) { + // Prepare arrays for batch operations + const districtsToCreate = []; + const geographicsToCreate = []; + const addressPromises = []; + const years = [2020, 2021, 2022, 2023, 2024]; + + // 2. Process all districts first to prepare data + for (let i = 0; i < districtGeoJson.features.length; i++) { + const feature = districtGeoJson.features[i]; const properties = feature.properties; - const geometry = feature.geometry; // Cleanup code const districtCode = properties.kode_kec.replace(/\./g, '').trim(); - // Insert District - const district = await this.prisma.districts.create({ - data: { - id: districtCode, - name: properties.kecamatan, - city_id: regency.id, - }, + // Add to districts batch + districtsToCreate.push({ + id: districtCode, + name: properties.kecamatan, + city_id: regency.id, }); - console.log(`Inserted district: ${district.name}`); - - // Buat Location satu kali untuk district (tidak dalam loop) + // Calculate centroid for each district const centroid = turf.centroid(feature); const [longitude, latitude] = centroid.geometry.coordinates; - const address = await this.getStreetFromMapbox(longitude, latitude); + // Create address lookup promise for this district + addressPromises.push(this.getStreetFromMapbox(longitude, latitude)); + } - // Insert locations for each year with appropriate land area - const years = [2020, 2021, 2022, 2023, 2024]; + // 3. Insert all districts at once + await this.prisma.districts.createMany({ + data: districtsToCreate, + skipDuplicates: true, + }); + console.log(`Inserted ${districtsToCreate.length} districts in batch`); + + // 4. Get all addresses in parallel + const addresses = await Promise.all(addressPromises); + + // 5. Prepare geographic data for batch insertion + for (let i = 0; i < districtGeoJson.features.length; i++) { + const feature = districtGeoJson.features[i]; + const properties = feature.properties; + const geometry = feature.geometry; + const districtCode = properties.kode_kec.replace(/\./g, '').trim(); + const districtName = properties.kecamatan; + const address = addresses[i]; + + // Calculate centroid + const centroid = turf.centroid(feature); + const [longitude, latitude] = centroid.geometry.coordinates; + + // Create geographic entries for each year for (const year of years) { - const landArea = this.getDistrictArea(district.name, year); + const landArea = this.getDistrictArea(districtName, year); - // Create location data for this district and year - const locationData: CreateLocationDto = { - district_id: district.id, - description: `Location for ${district.name} District in Jember (${year})`, + // Add to geographics batch + geographicsToCreate.push({ + district_id: districtCode, + description: `Location for ${districtName} District in Jember (${year})`, address: address, type: 'district location', year: year, @@ -181,26 +262,15 @@ export class GeoJSONSeeder { polygon: geometry, geometry: geometry, location: `POINT(${longitude} ${latitude})`, - }; - - const { error } = await this.supabase - .from('geographics') - .insert([locationData]) - .select(); - - if (error) { - console.error( - `Error inserting into locations for district ${district.name} (${year}):`, - error - ); - continue; - } - - console.log( - `Inserted geographic data for: ${district.name} (${year}) with area: ${landArea} sq km` - ); + }); } } + + // 6. Insert all geographic data in smaller batches + console.log( + `Preparing to insert ${geographicsToCreate.length} geographic records in smaller batches` + ); + await this.insertInBatches(geographicsToCreate); } console.log( diff --git a/sigap-website/prisma/seeds/permission.ts b/sigap-website/prisma/seeds/permission.ts index 06df246..d05a3dd 100644 --- a/sigap-website/prisma/seeds/permission.ts +++ b/sigap-website/prisma/seeds/permission.ts @@ -1,90 +1,97 @@ import { PrismaClient } from "@prisma/client"; export class PermissionSeeder { - constructor(private prisma: PrismaClient) { } + constructor(private prisma: PrismaClient) {} - async run(): Promise { - console.log('Seeding permissions...'); + async run(): Promise { + console.log('Seeding permissions...'); - // Delete existing permissions to avoid duplicates - await this.prisma.permissions.deleteMany({}); + // Delete existing permissions to avoid duplicates + await this.prisma.permissions.deleteMany({}); - try { - // Fetch all resources and roles - const allResources = await this.prisma.resources.findMany(); - const adminRole = await this.prisma.roles.findUnique({ - where: { name: 'admin' }, - }); - const viewerRole = await this.prisma.roles.findUnique({ - where: { name: 'viewer' }, - }); - const staffRole = await this.prisma.roles.findUnique({ - where: { name: 'staff' }, - }); + try { + // Fetch all resources and roles + const allResources = await this.prisma.resources.findMany(); + const adminRole = await this.prisma.roles.findUnique({ + where: { name: 'admin' }, + }); + const viewerRole = await this.prisma.roles.findUnique({ + where: { name: 'viewer' }, + }); + const staffRole = await this.prisma.roles.findUnique({ + where: { name: 'staff' }, + }); - if (!adminRole || !viewerRole || !staffRole) { - console.error('Roles not found. Please seed roles first.'); - return; - } + if (!adminRole || !viewerRole || !staffRole) { + console.error('Roles not found. Please seed roles first.'); + return; + } - // Admin permissions - full access to all resources - for (const resource of allResources) { - await this.createPermissions(adminRole.id, resource.id, [ + // Admin permissions - full access to all resources + for (const resource of allResources) { + await this.createPermissions(adminRole.id, resource.id, [ + 'create', + 'read', + 'update', + 'delete', + ]); + } + + // Viewer permissions - read-only access to all resources + for (const resource of allResources) { + await this.createPermissions(viewerRole.id, resource.id, ['read']); + } + + // Staff permissions - mixed permissions based on resource + for (const resource of allResources) { + if ( + ['roles', 'permissions', 'resources', 'users'].includes(resource.name) + ) { + // Staff can only read roles, permissions, resources, and users + await this.createPermissions(staffRole.id, resource.id, ['read']); + } else { + // Staff can create, read, update but not delete other resources + await this.createPermissions(staffRole.id, resource.id, [ 'create', 'read', 'update', - 'delete', ]); } - - // Viewer permissions - read-only access to all resources - for (const resource of allResources) { - await this.createPermissions(viewerRole.id, resource.id, ['read']); - } - - // Staff permissions - mixed permissions based on resource - for (const resource of allResources) { - if ( - ['roles', 'permissions', 'resources', 'users'].includes( - resource.name - ) - ) { - // Staff can only read roles, permissions, resources, and users - await this.createPermissions(staffRole.id, resource.id, ['read']); - } else { - // Staff can create, read, update but not delete other resources - await this.createPermissions(staffRole.id, resource.id, [ - 'create', - 'read', - 'update', - ]); - } - } - - console.log('Permissions seeded successfully!'); - } catch (error) { - console.error('Error seeding permissions:', error); } + + console.log('Permissions seeded successfully!'); + } catch (error) { + console.error('Error seeding permissions:', error); } + } - private async createPermissions(roleId: string, resourceId: string, actions: string[]) { - for (const action of actions) { - try { - const permission = await this.prisma.permissions.createMany({ - data: { - action: action, - resource_id: resourceId, - role_id: roleId, - }, - skipDuplicates: true, // Skip if the permission already exists - }); + private async createPermissions( + roleId: string, + resourceId: string, + actions: string[] + ) { + try { + // Prepare all permissions at once + const permissionsData = actions.map((action) => ({ + action: action, + resource_id: resourceId, + role_id: roleId, + })); + // Create all permissions in a single batch operation + const result = await this.prisma.permissions.createMany({ + data: permissionsData, + skipDuplicates: true, // Skip if the permission already exists + }); - console.log(`Created permission: ${action} for role ${roleId} on resource ${resourceId}`); - - } catch (error) { - console.error(`Error creating permission for role ${roleId} on resource ${resourceId}:`, error); - } - } + console.log( + `Created ${result.count} permissions for role ${roleId} on resource ${resourceId}: ${actions.join(', ')}` + ); + } catch (error) { + console.error( + `Error creating permissions for role ${roleId} on resource ${resourceId}:`, + error + ); } + } } \ No newline at end of file diff --git a/sigap-website/prisma/seeds/units.ts b/sigap-website/prisma/seeds/units.ts index 6d7e564..57e6a08 100644 --- a/sigap-website/prisma/seeds/units.ts +++ b/sigap-website/prisma/seeds/units.ts @@ -79,41 +79,6 @@ export class UnitSeeder { return; } - const location = await this.getUnitsLocation(city.name); - - if (!location) { - console.warn(`No location found for city: ${city.name}`); - return; - } - - const [lng, lat] = [location.lng, location.lat]; - const address = location.address; - const phone = location.telepon?.replace(/-/g, ''); - - const newId = await generateIdWithDbCounter('units', { - prefix: 'UT', - segments: { - sequentialDigits: 4, - }, - format: '{prefix}-{sequence}', - separator: '-', - uniquenessStrategy: 'counter', - }); - - let locationData: CreateLocationDto = { - district_id: city.districts[0].id, // This will be replaced with Patrang's ID - city_id: city.id, - code_unit: newId, - name: `Polres ${city.name}`, - description: `Unit ${city.name} is categorized as POLRES and operates in the ${city.name} area.`, - type: 'polres', - address, - phone, - longitude: lng, - latitude: lat, - location: `POINT(${lng} ${lat})`, - }; - // Find the Patrang district const patrangDistrict = await this.prisma.districts.findFirst({ where: { @@ -127,33 +92,18 @@ export class UnitSeeder { return; } - locationData.district_id = patrangDistrict.id; + // Prepare arrays for batch operations + const unitsToInsert = []; - const { error } = await this.supabase - .from('units') - .insert([locationData]) - .select(); + // First, get the Polres unit data + const polresLocation = await this.getUnitsLocation(city.name); - if (error) { - console.error(`Error inserting into Supabase locations:`, error); - return; - } + if (polresLocation) { + const [lng, lat] = [polresLocation.lng, polresLocation.lat]; + const address = polresLocation.address; + const phone = polresLocation.telepon?.replace(/-/g, ''); - let district; - - for (district of districts) { - const location = await this.getUnitsLocation(district.name); - - if (!location) { - console.warn(`No location found for district: ${district.name}`); - continue; - } - - const [lng, lat] = [location.lng, location.lat]; - const address = location.address; - const phone = location.telepon?.replace(/-/g, ''); - - const newId = await generateIdWithDbCounter('units', { + const polresId = await generateIdWithDbCounter('units', { prefix: 'UT', segments: { sequentialDigits: 4, @@ -163,7 +113,63 @@ export class UnitSeeder { uniquenessStrategy: 'counter', }); - const locationData: CreateLocationDto = { + unitsToInsert.push({ + district_id: patrangDistrict.id, + city_id: city.id, + code_unit: polresId, + name: `Polres ${city.name}`, + description: `Unit ${city.name} is categorized as POLRES and operates in the ${city.name} area.`, + type: 'polres', + address, + phone, + longitude: lng, + latitude: lat, + location: `POINT(${lng} ${lat})`, + }); + } else { + console.warn(`No location found for city: ${city.name}`); + } + + // Now prepare data for all Polseks + const locationPromises = districts.map((district) => + this.getUnitsLocation(district.name) + .then((location) => ({ district, location })) + .catch(() => ({ district, location: null })) + ); + + // Wait for all location lookups to complete + const results = await Promise.all(locationPromises); + + // Generate all IDs upfront + const idPromises = Array(results.length) + .fill(0) + .map(() => + generateIdWithDbCounter('units', { + prefix: 'UT', + segments: { + sequentialDigits: 4, + }, + format: '{prefix}-{sequence}', + separator: '-', + uniquenessStrategy: 'counter', + }) + ); + + const ids = await Promise.all(idPromises); + + // Process results and add to unitsToInsert + results.forEach(({ district, location }, index) => { + if (!location) { + console.warn(`No location found for district: ${district.name}`); + return; + } + + const [lng, lat] = [location.lng, location.lat]; + const address = location.address; + const phone = location.telepon?.replace(/-/g, ''); + const newId = ids[index]; + + unitsToInsert.push({ district_id: district.id, city_id: district.city_id, code_unit: newId, @@ -175,21 +181,29 @@ export class UnitSeeder { longitude: lng, latitude: lat, location: `POINT(${lng} ${lat})`, - }; + }); + console.log( + `Prepared unit data for district: ${district.name}, ID: ${newId}` + ); + }); + + // Insert all units in a single batch operation + if (unitsToInsert.length > 0) { const { error } = await this.supabase .from('units') - .insert([locationData]) + .insert(unitsToInsert) .select(); if (error) { - console.error(`Error inserting into Supabase locations:`, error); - continue; + console.error(`Error batch inserting units into Supabase:`, error); + } else { + console.log( + `Successfully inserted ${unitsToInsert.length} units in batch` + ); } - - console.log( - `Inserted unit for district: ${district.name}, newId: ${newId} at ${lng}, ${lat}` - ); + } else { + console.warn('No unit data to insert'); } } diff --git a/sigap-website/supabase/config.toml b/sigap-website/supabase/config.toml index b2f1780..e30011d 100644 --- a/sigap-website/supabase/config.toml +++ b/sigap-website/supabase/config.toml @@ -105,9 +105,9 @@ file_size_limit = "50MiB" enabled = true # The base URL of your website. Used as an allow-list for redirects and for constructing URLs used # in emails. -site_url = "https://127.0.0.1:3000" +site_url = "http://127.0.0.1:3000" # A list of *exact* URLs that auth providers are permitted to redirect to post authentication. -additional_redirect_urls = [] +additional_redirect_urls = ["https://127.0.0.1:3000"] # How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). jwt_expiry = 3600 # If disabled, the refresh token will never expire. @@ -122,7 +122,7 @@ enable_anonymous_sign_ins = false # Allow/disallow testing manual linking of accounts enable_manual_linking = false # Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. -minimum_password_length = 8 +minimum_password_length = 6 # Passwords that do not meet the following requirements will be rejected as weak. Supported values # are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols` password_requirements = "" @@ -154,7 +154,7 @@ enable_signup = true # addresses. If disabled, only the new email is required to confirm. double_confirm_changes = true # If enabled, users need to confirm their email address before signing in. -enable_confirmations = true +enable_confirmations = false # If enabled, users will need to reauthenticate or have logged in recently to change their password. secure_password_change = false # Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. @@ -162,7 +162,7 @@ max_frequency = "1s" # Number of characters used in the email OTP. otp_length = 6 # Number of seconds before the email OTP expires (defaults to 1 hour). -otp_expiry = 300 +otp_expiry = 3600 # Use a production-ready SMTP server # [auth.email.smtp] @@ -220,8 +220,8 @@ max_enrolled_factors = 10 # Control MFA via App Authenticator (TOTP) [auth.mfa.totp] -enroll_enabled = true -verify_enabled = true +enroll_enabled = false +verify_enabled = false # Configure MFA via Phone Messaging [auth.mfa.phone] diff --git a/sigap-website/supabase/migrations/20250506125310_remote_schema.sql b/sigap-website/supabase/migrations/20250506125310_remote_schema.sql new file mode 100644 index 0000000..8f567a1 --- /dev/null +++ b/sigap-website/supabase/migrations/20250506125310_remote_schema.sql @@ -0,0 +1,7 @@ +drop trigger if exists "on_auth_user_created" on "auth"."users"; + +drop trigger if exists "on_auth_user_deleted" on "auth"."users"; + +drop trigger if exists "on_auth_user_updated" on "auth"."users"; + + diff --git a/sigap-website/supabase/migrations/20250506125549_add_new_function.sql b/sigap-website/supabase/migrations/20250506125549_add_new_function.sql new file mode 100644 index 0000000..5ff6142 --- /dev/null +++ b/sigap-website/supabase/migrations/20250506125549_add_new_function.sql @@ -0,0 +1,203 @@ +-- Restore function: public.generate_username +CREATE OR REPLACE FUNCTION public.generate_username(email text) +RETURNS text +LANGUAGE plpgsql SECURITY DEFINER +AS $$ +DECLARE + result_username TEXT; + username_base TEXT; + random_number INTEGER; + username_exists BOOLEAN; +BEGIN + username_base := split_part(email, '@', 1); + username_base := regexp_replace(username_base, '[^a-zA-Z0-9]', '_', 'g'); + random_number := floor(random() * 9900 + 100)::integer; + result_username := username_base || random_number; + LOOP + SELECT EXISTS(SELECT 1 FROM public.profiles WHERE username = result_username) INTO username_exists; + IF NOT username_exists THEN + EXIT; + END IF; + random_number := floor(random() * 9900 + 100)::integer; + result_username := username_base || random_number; + END LOOP; + RETURN result_username; +END; +$$; + +-- Restore function: gis.update_land_area +CREATE OR REPLACE FUNCTION gis.update_land_area() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.land_area := ROUND((ST_Area(NEW.geometry::geography) / 1000000.0)::numeric, 2); + RETURN NEW; +END; +$$; + +-- Restore function: public.update_land_area +CREATE OR REPLACE FUNCTION public.update_land_area() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.land_area := ROUND(ST_Area(NEW.geometry::gis.geography) / 1000000.0); + RETURN NEW; +END; +$$; + +-- Restore function: public.update_timestamp +CREATE OR REPLACE FUNCTION public.update_timestamp() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$; + +-- Restore function: public.handle_new_user +CREATE OR REPLACE FUNCTION public.handle_new_user() +RETURNS trigger +LANGUAGE plpgsql SECURITY DEFINER +AS $$ +DECLARE + role_id UUID; +BEGIN + SELECT id INTO role_id FROM public.roles WHERE name = 'viewer' LIMIT 1; + IF role_id IS NULL THEN + RAISE EXCEPTION 'Role not found'; + END IF; + INSERT INTO public.users ( + id, + roles_id, + email, + phone, + encrypted_password, + invited_at, + confirmed_at, + email_confirmed_at, + recovery_sent_at, + last_sign_in_at, + app_metadata, + user_metadata, + created_at, + updated_at, + banned_until, + is_anonymous + ) VALUES ( + NEW.id, + role_id, + NEW.email, + NEW.phone, + NEW.encrypted_password, + NEW.invited_at, + NEW.confirmed_at, + NEW.email_confirmed_at, + NEW.recovery_sent_at, + NEW.last_sign_in_at, + NEW.raw_app_meta_data, + NEW.raw_user_meta_data, + NEW.created_at, + NEW.updated_at, + NEW.banned_until, + NEW.is_anonymous + ); + INSERT INTO public.profiles ( + id, + user_id, + avatar, + username, + first_name, + last_name, + bio, + address, + birth_date + ) VALUES ( + gen_random_uuid(), + NEW.id, + NULL, + public.generate_username(NEW.email), + NULL, + NULL, + NULL, + NULL, + NULL + ); + RETURN NEW; +END; +$$; + +-- Restore function: public.handle_user_delete +CREATE OR REPLACE FUNCTION public.handle_user_delete() +RETURNS trigger +LANGUAGE plpgsql SECURITY DEFINER +AS $$ +BEGIN + DELETE FROM public.profiles WHERE user_id = OLD.id; + DELETE FROM public.users WHERE id = OLD.id; + RETURN OLD; +END; +$$; + +-- Restore function: public.handle_user_update +CREATE OR REPLACE FUNCTION public.handle_user_update() +RETURNS trigger +LANGUAGE plpgsql SECURITY DEFINER +AS $$ +BEGIN + UPDATE public.users + SET + email = COALESCE(NEW.email, email), + phone = COALESCE(NEW.phone, phone), + encrypted_password = COALESCE(NEW.encrypted_password, encrypted_password), + invited_at = COALESCE(NEW.invited_at, invited_at), + confirmed_at = COALESCE(NEW.confirmed_at, confirmed_at), + email_confirmed_at = COALESCE(NEW.email_confirmed_at, email_confirmed_at), + recovery_sent_at = COALESCE(NEW.recovery_sent_at, recovery_sent_at), + last_sign_in_at = COALESCE(NEW.last_sign_in_at, last_sign_in_at), + app_metadata = COALESCE(NEW.raw_app_meta_data, app_metadata), + user_metadata = COALESCE(NEW.raw_user_meta_data, user_metadata), + created_at = COALESCE(NEW.created_at, created_at), + updated_at = NOW(), + banned_until = CASE + WHEN NEW.banned_until IS NULL THEN NULL + ELSE COALESCE(NEW.banned_until, banned_until) + END, + is_anonymous = COALESCE(NEW.is_anonymous, is_anonymous) + WHERE id = NEW.id; + INSERT INTO public.profiles (id, user_id) + SELECT gen_random_uuid(), NEW.id + WHERE NOT EXISTS ( + SELECT 1 FROM public.profiles WHERE user_id = NEW.id + ) + ON CONFLICT (user_id) DO NOTHING; + RETURN NEW; +END; +$$; + +-- Function: public.handle_new_user() +-- Already exists in schema, so just create trigger + +CREATE TRIGGER "on_auth_user_created" +AFTER INSERT ON "auth"."users" +FOR EACH ROW +EXECUTE FUNCTION public.handle_new_user(); + +-- Function: public.handle_user_delete() +-- Already exists in schema, so just create trigger + +CREATE TRIGGER "on_auth_user_deleted" +AFTER DELETE ON "auth"."users" +FOR EACH ROW +EXECUTE FUNCTION public.handle_user_delete(); + +-- Function: public.handle_user_update() +-- Already exists in schema, so just create trigger + +CREATE TRIGGER "on_auth_user_updated" +AFTER UPDATE ON "auth"."users" +FOR EACH ROW +EXECUTE FUNCTION public.handle_user_update(); \ No newline at end of file diff --git a/sigap-website/supabase/migrations/20250506132018_add_new_gis_function.sql b/sigap-website/supabase/migrations/20250506132018_add_new_gis_function.sql new file mode 100644 index 0000000..f1b2483 --- /dev/null +++ b/sigap-website/supabase/migrations/20250506132018_add_new_gis_function.sql @@ -0,0 +1,163 @@ +-- drop type "gis"."geometry_dump"; + +-- drop type "gis"."valid_detail"; + +-- set check_function_bodies = off; + +DROP FUNCTION IF EXISTS gis.calculate_unit_incident_distances(VARCHAR, VARCHAR); + +DROP FUNCTION IF EXISTS gis.find_nearest_unit(character varying); + +DROP FUNCTION IF EXISTS gis.find_units_within_distance(character varying, double precision); + +-- DROP FUNCTION IF EXISTS gis.find_units_within_distance(character varying, double precision DEFAULT 5000); + + + +CREATE OR REPLACE FUNCTION gis.calculate_unit_incident_distances( + p_unit_id VARCHAR, + p_district_id VARCHAR DEFAULT NULL +) +RETURNS TABLE ( + unit_code VARCHAR, + incident_id VARCHAR, + district_name VARCHAR, + distance_meters FLOAT +) AS $$ +BEGIN + RETURN QUERY + WITH unit_locations AS ( + SELECT + u.code_unit, + u.district_id, + ST_SetSRID(ST_MakePoint(u.longitude, u.latitude), 4326)::gis.geography AS location + FROM + units u + WHERE + (p_unit_id IS NULL OR u.code_unit = p_unit_id) + AND (p_district_id IS NULL OR u.district_id = p_district_id) + AND u.latitude IS NOT NULL + AND u.longitude IS NOT NULL + ), + incident_locations AS ( + SELECT + ci.id, + ci.crime_id, + ci.crime_category_id, + ST_SetSRID(ST_MakePoint(l.longitude, l.latitude), 4326)::gis.geography AS location + FROM + crime_incidents ci + JOIN + locations l ON ci.location_id = l.id + WHERE + l.latitude IS NOT NULL + AND l.longitude IS NOT NULL + ) + SELECT + ul.code_unit as unit_code, + il.id as incident_id, + d.name as district_name, + ST_Distance(ul.location, il.location) as distance_meters + FROM + unit_locations ul + JOIN + districts d ON ul.district_id = d.id + JOIN + crimes c ON c.district_id = d.id + JOIN + incident_locations il ON il.crime_id = c.id + ORDER BY + ul.code_unit, + ul.location <-> il.location; +END; +$$ LANGUAGE plpgsql; + + +CREATE OR REPLACE FUNCTION gis.find_nearest_unit(p_incident_id character varying) + RETURNS TABLE(unit_code character varying, unit_name character varying, distance_meters double precision) + LANGUAGE plpgsql + SECURITY DEFINER +AS $function$ +BEGIN + RETURN QUERY + WITH incident_location AS ( + SELECT + ci.id, + l.location AS location + FROM + crime_incidents ci + JOIN + locations l ON ci.location_id = l.id + WHERE + ci.id = p_incident_id + ), + unit_locations AS ( + SELECT + u.code_unit, + u.name, + u.location + FROM + units u + ) + SELECT + ul.code_unit as unit_code, + ul.name as unit_name, + ST_Distance(ul.location, il.location) as distance_meters + FROM + unit_locations ul + CROSS JOIN + incident_location il + ORDER BY + ul.location <-> il.location + LIMIT 1; +END; +$function$ +; + +CREATE OR REPLACE FUNCTION gis.find_units_within_distance(p_incident_id character varying, p_max_distance_meters double precision DEFAULT 5000) + RETURNS TABLE(unit_code character varying, unit_name character varying, distance_meters double precision) + LANGUAGE plpgsql + SECURITY DEFINER +AS $function$ +BEGIN + RETURN QUERY + WITH incident_location AS ( + SELECT + ci.id, + l.location AS location + FROM + crime_incidents ci + JOIN + locations l ON ci.location_id = l.id + WHERE + ci.id = p_incident_id + ), + unit_locations AS ( + SELECT + u.code_unit, + u.name, + u.location + FROM + units u + ) + SELECT + ul.code_unit as unit_code, + ul.name as unit_name, + ST_Distance(ul.location, il.location) as distance_meters + FROM + unit_locations ul + CROSS JOIN + incident_location il + WHERE + ST_DWithin(ul.location, il.location, p_max_distance_meters) + ORDER BY + ST_Distance(ul.location, il.location); +END; +$function$ +; + +-- create type "gis"."geometry_dump" as ("path" integer[], "geom" geometry); + +-- create type "gis"."valid_detail" as ("valid" boolean, "reason" character varying, "location" geometry); + + diff --git a/sigap-website/supabase/migrations/20250506142627_drop_and_restore.sql b/sigap-website/supabase/migrations/20250506142627_drop_and_restore.sql new file mode 100644 index 0000000..f1b2483 --- /dev/null +++ b/sigap-website/supabase/migrations/20250506142627_drop_and_restore.sql @@ -0,0 +1,163 @@ +-- drop type "gis"."geometry_dump"; + +-- drop type "gis"."valid_detail"; + +-- set check_function_bodies = off; + +DROP FUNCTION IF EXISTS gis.calculate_unit_incident_distances(VARCHAR, VARCHAR); + +DROP FUNCTION IF EXISTS gis.find_nearest_unit(character varying); + +DROP FUNCTION IF EXISTS gis.find_units_within_distance(character varying, double precision); + +-- DROP FUNCTION IF EXISTS gis.find_units_within_distance(character varying, double precision DEFAULT 5000); + + + +CREATE OR REPLACE FUNCTION gis.calculate_unit_incident_distances( + p_unit_id VARCHAR, + p_district_id VARCHAR DEFAULT NULL +) +RETURNS TABLE ( + unit_code VARCHAR, + incident_id VARCHAR, + district_name VARCHAR, + distance_meters FLOAT +) AS $$ +BEGIN + RETURN QUERY + WITH unit_locations AS ( + SELECT + u.code_unit, + u.district_id, + ST_SetSRID(ST_MakePoint(u.longitude, u.latitude), 4326)::gis.geography AS location + FROM + units u + WHERE + (p_unit_id IS NULL OR u.code_unit = p_unit_id) + AND (p_district_id IS NULL OR u.district_id = p_district_id) + AND u.latitude IS NOT NULL + AND u.longitude IS NOT NULL + ), + incident_locations AS ( + SELECT + ci.id, + ci.crime_id, + ci.crime_category_id, + ST_SetSRID(ST_MakePoint(l.longitude, l.latitude), 4326)::gis.geography AS location + FROM + crime_incidents ci + JOIN + locations l ON ci.location_id = l.id + WHERE + l.latitude IS NOT NULL + AND l.longitude IS NOT NULL + ) + SELECT + ul.code_unit as unit_code, + il.id as incident_id, + d.name as district_name, + ST_Distance(ul.location, il.location) as distance_meters + FROM + unit_locations ul + JOIN + districts d ON ul.district_id = d.id + JOIN + crimes c ON c.district_id = d.id + JOIN + incident_locations il ON il.crime_id = c.id + ORDER BY + ul.code_unit, + ul.location <-> il.location; +END; +$$ LANGUAGE plpgsql; + + +CREATE OR REPLACE FUNCTION gis.find_nearest_unit(p_incident_id character varying) + RETURNS TABLE(unit_code character varying, unit_name character varying, distance_meters double precision) + LANGUAGE plpgsql + SECURITY DEFINER +AS $function$ +BEGIN + RETURN QUERY + WITH incident_location AS ( + SELECT + ci.id, + l.location AS location + FROM + crime_incidents ci + JOIN + locations l ON ci.location_id = l.id + WHERE + ci.id = p_incident_id + ), + unit_locations AS ( + SELECT + u.code_unit, + u.name, + u.location + FROM + units u + ) + SELECT + ul.code_unit as unit_code, + ul.name as unit_name, + ST_Distance(ul.location, il.location) as distance_meters + FROM + unit_locations ul + CROSS JOIN + incident_location il + ORDER BY + ul.location <-> il.location + LIMIT 1; +END; +$function$ +; + +CREATE OR REPLACE FUNCTION gis.find_units_within_distance(p_incident_id character varying, p_max_distance_meters double precision DEFAULT 5000) + RETURNS TABLE(unit_code character varying, unit_name character varying, distance_meters double precision) + LANGUAGE plpgsql + SECURITY DEFINER +AS $function$ +BEGIN + RETURN QUERY + WITH incident_location AS ( + SELECT + ci.id, + l.location AS location + FROM + crime_incidents ci + JOIN + locations l ON ci.location_id = l.id + WHERE + ci.id = p_incident_id + ), + unit_locations AS ( + SELECT + u.code_unit, + u.name, + u.location + FROM + units u + ) + SELECT + ul.code_unit as unit_code, + ul.name as unit_name, + ST_Distance(ul.location, il.location) as distance_meters + FROM + unit_locations ul + CROSS JOIN + incident_location il + WHERE + ST_DWithin(ul.location, il.location, p_max_distance_meters) + ORDER BY + ST_Distance(ul.location, il.location); +END; +$function$ +; + +-- create type "gis"."geometry_dump" as ("path" integer[], "geom" geometry); + +-- create type "gis"."valid_detail" as ("valid" boolean, "reason" character varying, "location" geometry); + + diff --git a/sigap-website/supabase/migrations/20250506142701_remote_schema.sql b/sigap-website/supabase/migrations/20250506142701_remote_schema.sql new file mode 100644 index 0000000..af7930e --- /dev/null +++ b/sigap-website/supabase/migrations/20250506142701_remote_schema.sql @@ -0,0 +1,9 @@ +-- drop type "gis"."geometry_dump"; + +-- drop type "gis"."valid_detail"; + +-- create type "gis"."geometry_dump" as ("path" integer[], "geom" geometry); + +-- create type "gis"."valid_detail" as ("valid" boolean, "reason" character varying, "location" geometry); + + diff --git a/sigap-website/supabase/migrations/20250506143002_drop_and_restore.sql b/sigap-website/supabase/migrations/20250506143002_drop_and_restore.sql new file mode 100644 index 0000000..f1b2483 --- /dev/null +++ b/sigap-website/supabase/migrations/20250506143002_drop_and_restore.sql @@ -0,0 +1,163 @@ +-- drop type "gis"."geometry_dump"; + +-- drop type "gis"."valid_detail"; + +-- set check_function_bodies = off; + +DROP FUNCTION IF EXISTS gis.calculate_unit_incident_distances(VARCHAR, VARCHAR); + +DROP FUNCTION IF EXISTS gis.find_nearest_unit(character varying); + +DROP FUNCTION IF EXISTS gis.find_units_within_distance(character varying, double precision); + +-- DROP FUNCTION IF EXISTS gis.find_units_within_distance(character varying, double precision DEFAULT 5000); + + + +CREATE OR REPLACE FUNCTION gis.calculate_unit_incident_distances( + p_unit_id VARCHAR, + p_district_id VARCHAR DEFAULT NULL +) +RETURNS TABLE ( + unit_code VARCHAR, + incident_id VARCHAR, + district_name VARCHAR, + distance_meters FLOAT +) AS $$ +BEGIN + RETURN QUERY + WITH unit_locations AS ( + SELECT + u.code_unit, + u.district_id, + ST_SetSRID(ST_MakePoint(u.longitude, u.latitude), 4326)::gis.geography AS location + FROM + units u + WHERE + (p_unit_id IS NULL OR u.code_unit = p_unit_id) + AND (p_district_id IS NULL OR u.district_id = p_district_id) + AND u.latitude IS NOT NULL + AND u.longitude IS NOT NULL + ), + incident_locations AS ( + SELECT + ci.id, + ci.crime_id, + ci.crime_category_id, + ST_SetSRID(ST_MakePoint(l.longitude, l.latitude), 4326)::gis.geography AS location + FROM + crime_incidents ci + JOIN + locations l ON ci.location_id = l.id + WHERE + l.latitude IS NOT NULL + AND l.longitude IS NOT NULL + ) + SELECT + ul.code_unit as unit_code, + il.id as incident_id, + d.name as district_name, + ST_Distance(ul.location, il.location) as distance_meters + FROM + unit_locations ul + JOIN + districts d ON ul.district_id = d.id + JOIN + crimes c ON c.district_id = d.id + JOIN + incident_locations il ON il.crime_id = c.id + ORDER BY + ul.code_unit, + ul.location <-> il.location; +END; +$$ LANGUAGE plpgsql; + + +CREATE OR REPLACE FUNCTION gis.find_nearest_unit(p_incident_id character varying) + RETURNS TABLE(unit_code character varying, unit_name character varying, distance_meters double precision) + LANGUAGE plpgsql + SECURITY DEFINER +AS $function$ +BEGIN + RETURN QUERY + WITH incident_location AS ( + SELECT + ci.id, + l.location AS location + FROM + crime_incidents ci + JOIN + locations l ON ci.location_id = l.id + WHERE + ci.id = p_incident_id + ), + unit_locations AS ( + SELECT + u.code_unit, + u.name, + u.location + FROM + units u + ) + SELECT + ul.code_unit as unit_code, + ul.name as unit_name, + ST_Distance(ul.location, il.location) as distance_meters + FROM + unit_locations ul + CROSS JOIN + incident_location il + ORDER BY + ul.location <-> il.location + LIMIT 1; +END; +$function$ +; + +CREATE OR REPLACE FUNCTION gis.find_units_within_distance(p_incident_id character varying, p_max_distance_meters double precision DEFAULT 5000) + RETURNS TABLE(unit_code character varying, unit_name character varying, distance_meters double precision) + LANGUAGE plpgsql + SECURITY DEFINER +AS $function$ +BEGIN + RETURN QUERY + WITH incident_location AS ( + SELECT + ci.id, + l.location AS location + FROM + crime_incidents ci + JOIN + locations l ON ci.location_id = l.id + WHERE + ci.id = p_incident_id + ), + unit_locations AS ( + SELECT + u.code_unit, + u.name, + u.location + FROM + units u + ) + SELECT + ul.code_unit as unit_code, + ul.name as unit_name, + ST_Distance(ul.location, il.location) as distance_meters + FROM + unit_locations ul + CROSS JOIN + incident_location il + WHERE + ST_DWithin(ul.location, il.location, p_max_distance_meters) + ORDER BY + ST_Distance(ul.location, il.location); +END; +$function$ +; + +-- create type "gis"."geometry_dump" as ("path" integer[], "geom" geometry); + +-- create type "gis"."valid_detail" as ("valid" boolean, "reason" character varying, "location" geometry); + + diff --git a/sigap-website/supabase/migrations/20250506143054_remote_schema.sql b/sigap-website/supabase/migrations/20250506143054_remote_schema.sql new file mode 100644 index 0000000..9b69d21 --- /dev/null +++ b/sigap-website/supabase/migrations/20250506143054_remote_schema.sql @@ -0,0 +1,9 @@ +drop type "gis"."geometry_dump"; + +drop type "gis"."valid_detail"; + +create type "gis"."geometry_dump" as ("path" integer[], "geom" geometry); + +create type "gis"."valid_detail" as ("valid" boolean, "reason" character varying, "location" geometry); + +