Refactor database privileges and schema management

- Created migration scripts to manage the 'prisma' role and its privileges across various schemas including 'public', 'gis', 'auth', and 'storage'.
- Added explicit grants for all necessary tables, sequences, and functions to ensure the 'prisma' role has appropriate access.
- Implemented triggers for user management in the 'auth' schema to handle new user creation, updates, and deletions.
- Established default privileges for future objects in the specified schemas to streamline permission management.
- Updated remote schema management scripts to include grants for 'postgres' on specific tables and functions.
- Dropped and recreated types in the 'gis' schema to ensure proper structure and functionality.
This commit is contained in:
vergiLgood1 2025-05-05 23:39:01 +07:00
parent fa7651619b
commit 0747897fc7
27 changed files with 3678 additions and 184 deletions

View File

@ -1,6 +1,12 @@
'use server';
import { ICrimes, ICrimesByYearAndMonth } from '@/app/_utils/types/crimes';
import { createClient } from '@/app/_utils/supabase/client';
import {
ICrimes,
ICrimesByYearAndMonth,
IDistanceResult,
} from '@/app/_utils/types/crimes';
import { getInjection } from '@/di/container';
import db from '@/prisma/db';
import {
@ -304,3 +310,38 @@ export async function getCrimeByYearAndMonth(
}
);
}
/**
* Calculate distances between units and incidents using PostGIS
* @param unitId Optional unit code to filter by specific unit
* @param districtId Optional district ID to filter by specific district
* @returns Array of distance calculations between units and incidents
*/
export async function calculateDistances(
unitId?: string,
districtId?: string
): Promise<IDistanceResult[]> {
const supabase = createClient();
try {
const { data, error } = await supabase.rpc(
'calculate_unit_incident_distances',
{
unit_id: unitId || null,
district_id: districtId || null,
}
);
if (error) {
console.error('Error calculating distances:', error);
return [];
}
return data || [];
} catch (error) {
console.error('Failed to calculate distances:', error);
return [];
}
}

View File

@ -228,8 +228,9 @@ export default function Layers({
// District fill should only be visible for incidents and clusters
const showDistrictFill = activeControl === "incidents" || activeControl === "clusters";
// Only show incident markers for incidents and clusters views - exclude timeline mode
const showIncidentMarkers = (activeControl === "incidents" || activeControl === "clusters")
// Show incident markers for incidents, clusters, AND units modes
// But hide for heatmap and timeline
const showIncidentMarkers = activeControl !== "heatmap" && activeControl !== "timeline";
return (
<>
@ -270,7 +271,7 @@ export default function Layers({
useAllData={useAllData}
/>
{/* Units Layer */}
{/* Units Layer - always show incidents when Units is active */}
<UnitsLayer
crimes={crimes}
units={units}
@ -290,18 +291,18 @@ export default function Layers({
{/* Cluster Layer - only enable when the clusters control is active and NOT in timeline mode */}
<ClusterLayer
visible={visible && !showTimelineLayer}
visible={visible && activeControl === "clusters"}
map={mapboxMap}
crimes={crimes}
filterCategory={filterCategory}
focusedDistrictId={focusedDistrictId}
clusteringEnabled={showClustersLayer}
showClusters={showClustersLayer}
clusteringEnabled={activeControl === "clusters"}
showClusters={activeControl === "clusters"}
/>
{/* Unclustered Points Layer - explicitly hide in timeline mode */}
{/* Unclustered Points Layer - now show for both incidents and units modes */}
<UnclusteredPointLayer
visible={visible && showIncidentMarkers && !focusedDistrictId && !showTimelineLayer}
visible={visible && showIncidentMarkers && !focusedDistrictId}
map={mapboxMap}
crimes={crimes}
filterCategory={filterCategory}

View File

@ -2,10 +2,15 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Layer, Source } from "react-map-gl/mapbox"
import { ICrimes } from "@/app/_utils/types/crimes"
import { ICrimes, IDistanceResult } from "@/app/_utils/types/crimes"
import { IUnits } from "@/app/_utils/types/units"
import mapboxgl from 'mapbox-gl'
import { useQuery } from '@tanstack/react-query'
import { generateCategoryColorMap, getCategoryColor } from '@/app/_utils/colors'
import UnitPopup from '../pop-up/unit-popup'
import IncidentPopup from '../pop-up/incident-popup'
import { calculateDistances } from '@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/action'
interface UnitsLayerProps {
crimes: ICrimes[]
@ -15,6 +20,23 @@ interface UnitsLayerProps {
map?: mapboxgl.Map | null
}
// Custom hook for fetching distance data
const useDistanceData = (entityId?: string, isUnit: boolean = false, districtId?: string) => {
// Skip the query when no entity is selected
return useQuery({
queryKey: ['distance-incidents', entityId, isUnit, districtId],
queryFn: async () => {
if (!entityId) return [];
const unitId = isUnit ? entityId : undefined;
const result = await calculateDistances(unitId, districtId);
return result;
},
enabled: !!entityId, // Only run query when there's an entityId
staleTime: 5 * 60 * 1000, // Consider data fresh for 5 minutes
gcTime: 10 * 60 * 1000, // Keep cache for 10 minutes
});
};
export default function UnitsLayer({
crimes,
units = [],
@ -25,6 +47,20 @@ export default function UnitsLayer({
const [loadedUnits, setLoadedUnits] = useState<IUnits[]>([])
const loadedUnitsRef = useRef<IUnits[]>([])
// For popups
const [selectedUnit, setSelectedUnit] = useState<IUnits | null>(null)
const [selectedIncident, setSelectedIncident] = useState<any | null>(null)
const [selectedEntityId, setSelectedEntityId] = useState<string | undefined>()
const [isUnitSelected, setIsUnitSelected] = useState<boolean>(false)
const [selectedDistrictId, setSelectedDistrictId] = useState<string | undefined>()
// Use react-query for distance data
const { data: distances = [], isLoading: isLoadingDistances } = useDistanceData(
selectedEntityId,
isUnitSelected,
selectedDistrictId
);
// Use either provided units or loaded units
const unitsData = useMemo(() => {
return units.length > 0 ? units : (loadedUnits || [])
@ -74,6 +110,44 @@ export default function UnitsLayer({
}
}, [unitsData])
// Process incident data to GeoJSON format
const incidentsGeoJSON = useMemo(() => {
const features: any[] = [];
crimes.forEach(crime => {
crime.crime_incidents.forEach(incident => {
// Skip incidents without location data or filtered by category
if (
!incident.locations?.latitude ||
!incident.locations?.longitude ||
(filterCategory !== "all" && incident.crime_categories.name !== filterCategory)
) return;
features.push({
type: "Feature" as const,
properties: {
id: incident.id,
description: incident.description || "No description",
category: incident.crime_categories.name,
date: incident.timestamp,
district: crime.districts?.name || "",
district_id: crime.district_id,
categoryColor: categoryColorMap[incident.crime_categories.name] || '#22c55e',
},
geometry: {
type: "Point" as const,
coordinates: [incident.locations.longitude, incident.locations.latitude]
}
});
});
});
return {
type: "FeatureCollection" as const,
features
};
}, [crimes, filterCategory, categoryColorMap]);
// Create lines between units and incidents within their districts
const connectionLinesGeoJSON = useMemo(() => {
if (!unitsData.length || !crimes.length) return {
@ -143,7 +217,6 @@ export default function UnitsLayer({
}
}, [unitsData, crimes, filterCategory, categoryColorMap])
// Map click handler code and the rest remains the same...
useEffect(() => {
if (!map || !visible) return
@ -155,22 +228,16 @@ export default function UnitsLayer({
if (!properties) return
// Create a popup for the unit
const popup = new mapboxgl.Popup()
.setLngLat(feature.geometry.type === 'Point' ?
(feature.geometry as any).coordinates as [number, number] :
[0, 0]) // Fallback coordinates if not a Point geometry
.setHTML(`
<div class="p-2">
<h3 class="font-bold text-base">${properties.name}</h3>
<p class="text-sm">${properties.type}</p>
<p class="text-sm">${properties.address || 'No address provided'}</p>
<p class="text-xs mt-2">Staff: ${properties.staff_count || 'N/A'}</p>
<p class="text-xs">Phone: ${properties.phone || 'N/A'}</p>
<p class="text-xs">District: ${properties.district || 'N/A'}</p>
</div>
`)
.addTo(map)
// Find the unit in our data
const unit = unitsData.find(u => u.code_unit === properties.id);
if (!unit) return;
// Set the selected unit and query parameters
setSelectedUnit(unit);
setSelectedIncident(null); // Clear any selected incident
setSelectedEntityId(properties.id);
setIsUnitSelected(true);
setSelectedDistrictId(properties.district_id);
// Highlight the connected lines for this unit
if (map.getLayer('units-connection-lines')) {
@ -180,15 +247,49 @@ export default function UnitsLayer({
properties.id
])
}
// When popup closes, reset the lines filter
popup.on('close', () => {
if (map.getLayer('units-connection-lines')) {
map.setFilter('units-connection-lines', ['has', 'unit_id'])
}
})
}
const handleIncidentClick = (e: mapboxgl.MapMouseEvent & { features?: mapboxgl.MapboxGeoJSONFeature[] }) => {
if (!e.features || e.features.length === 0) return;
const feature = e.features[0];
const properties = feature.properties;
if (!properties) return;
// Create incident object from properties
const incident = {
id: properties.id,
category: properties.category,
description: properties.description,
date: properties.date,
district: properties.district,
district_id: properties.district_id,
};
// Set the selected incident and query parameters
setSelectedIncident({
...incident,
latitude: feature.geometry.type === 'Point' ?
(feature.geometry as any).coordinates[1] : 0,
longitude: feature.geometry.type === 'Point' ?
(feature.geometry as any).coordinates[0] : 0
});
setSelectedUnit(null);
setSelectedEntityId(properties.id);
setIsUnitSelected(false);
setSelectedDistrictId(properties.district_id);
// Highlight the connected lines for this incident
if (map.getLayer('units-connection-lines')) {
map.setFilter('units-connection-lines', [
'==',
['get', 'incident_id'],
properties.id
]);
}
};
// Define event handlers that can be referenced for both adding and removing
const handleMouseEnter = () => {
map.getCanvas().style.cursor = 'pointer'
@ -207,14 +308,41 @@ export default function UnitsLayer({
map.on('mouseleave', 'units-points', handleMouseLeave)
}
// Add click event for incidents-points layer
if (map.getLayer('incidents-points')) {
map.on('click', 'incidents-points', handleIncidentClick)
// Change cursor on hover
map.on('mouseenter', 'incidents-points', handleMouseEnter)
map.on('mouseleave', 'incidents-points', handleMouseLeave)
}
return () => {
if (map.getLayer('units-points')) {
map.off('click', 'units-points', handleUnitClick)
map.off('mouseenter', 'units-points', handleMouseEnter)
map.off('mouseleave', 'units-points', handleMouseLeave)
}
if (map.getLayer('incidents-points')) {
map.off('click', 'incidents-points', handleIncidentClick)
map.off('mouseenter', 'incidents-points', handleMouseEnter)
map.off('mouseleave', 'incidents-points', handleMouseLeave)
}
}
}, [map, visible])
}, [map, visible, unitsData])
// Reset map filters when popup is closed
const handleClosePopup = () => {
setSelectedUnit(null);
setSelectedIncident(null);
setSelectedEntityId(undefined);
setSelectedDistrictId(undefined);
if (map && map.getLayer('units-connection-lines')) {
map.setFilter('units-connection-lines', ['has', 'unit_id']);
}
};
if (!visible) return null
@ -255,6 +383,22 @@ export default function UnitsLayer({
/>
</Source>
{/* Incidents Points */}
<Source id="incidents-source" type="geojson" data={incidentsGeoJSON}>
<Layer
id="incidents-points"
type="circle"
paint={{
'circle-radius': 6,
// Use the pre-computed color stored in the properties
'circle-color': ['get', 'categoryColor'],
'circle-stroke-width': 1,
'circle-stroke-color': '#ffffff',
'circle-opacity': 0.8
}}
/>
</Source>
{/* Connection Lines */}
<Source id="units-lines-source" type="geojson" data={connectionLinesGeoJSON}>
<Layer
@ -263,12 +407,45 @@ export default function UnitsLayer({
paint={{
// Use the pre-computed color stored in the properties
'line-color': ['get', 'lineColor'],
'line-width': 1.5,
'line-opacity': 0.7,
'line-dasharray': [1, 2] // Dashed line
'line-width': 3,
'line-opacity': 0.9,
'line-blur': 0.5,
'line-dasharray': [3, 1],
}}
/>
</Source>
{/* Custom Unit Popup */}
{selectedUnit && (
<UnitPopup
longitude={selectedUnit.longitude || 0}
latitude={selectedUnit.latitude || 0}
onClose={handleClosePopup}
unit={{
id: selectedUnit.code_unit,
name: selectedUnit.name,
type: selectedUnit.type,
address: selectedUnit.address || "No address",
phone: selectedUnit.phone || "No phone",
district: selectedUnit.districts?.name,
district_id: selectedUnit.district_id
}}
distances={distances}
isLoadingDistances={isLoadingDistances}
/>
)}
{/* Custom Incident Popup */}
{selectedIncident && (
<IncidentPopup
longitude={selectedIncident.longitude}
latitude={selectedIncident.latitude}
onClose={handleClosePopup}
incident={selectedIncident}
distances={distances}
isLoadingDistances={isLoadingDistances}
/>
)}
</>
)
}

View File

@ -0,0 +1,108 @@
"use client"
import { calculateDistances } from '@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/action'
import { useState, useEffect } from 'react'
import { Skeleton } from '../../ui/skeleton'
import { formatDistance } from '@/app/_utils/map'
import { useQuery } from '@tanstack/react-query'
interface DistanceInfoProps {
unitId?: string
districtId?: string
className?: string
}
export default function DistanceInfo({ unitId, districtId, className = '' }: DistanceInfoProps) {
const { data, isLoading } = useQuery({
queryKey: ['calculate-distances', unitId, districtId],
queryFn: () => calculateDistances(unitId, districtId),
})
if (isLoading) {
return (
<div className={`p-4 rounded bg-white shadow-sm ${className}`}>
<h3 className="text-lg font-semibold mb-2">Distance Information</h3>
<Skeleton className="h-4 w-full mb-2" />
<Skeleton className="h-4 w-full mb-2" />
<Skeleton className="h-4 w-full" />
</div>
)
}
if (!data) {
return (
<div className={`p-4 rounded bg-white shadow-sm ${className}`}>
<h3 className="text-lg font-semibold mb-2">Distance Information</h3>
<p className="text-sm text-gray-500">No distance data available</p>
</div>
)
}
if (!data.length) {
return (
<div className={`p-4 rounded bg-white shadow-sm ${className}`}>
<h3 className="text-lg font-semibold mb-2">Distance Information</h3>
<p className="text-sm text-gray-500">No distance data available</p>
</div>
)
}
// Group by unit if we're showing multiple units
const unitGroups = !unitId ?
data.reduce((acc, item) => {
if (!acc[item.unit_code]) {
acc[item.unit_code] = {
name: item.unit_name,
incidents: []
}
}
acc[item.unit_code].incidents.push(item)
return acc
}, {} as Record<string, { name: string, incidents: any[] }>) :
null
return (
<div className={`p-4 rounded bg-white shadow-sm ${className}`}>
<h3 className="text-lg font-semibold mb-2">Distance Information</h3>
{unitId ? (
// Single unit view
<div>
<h4 className="font-medium text-sm">{data[0]?.unit_name || 'Selected Unit'}</h4>
<ul className="mt-2 space-y-2">
{data.map(item => (
<li key={item.incident_id} className="text-xs flex justify-between border-b pb-1">
<span className="font-medium">{item.category_name}</span>
<span className="text-gray-600">{formatDistance(item.distance_meters)}</span>
</li>
))}
</ul>
</div>
) : (
// Multi-unit view (grouped)
<div className="space-y-4">
{unitGroups && Object.entries(unitGroups).map(([code, unit]) => (
<div key={code}>
<h4 className="font-medium text-sm">{unit.name}</h4>
<ul className="mt-1">
{unit.incidents.slice(0, 3).map(item => (
<li key={item.incident_id} className="text-xs flex justify-between border-b pb-1">
<span>{item.category_name}</span>
<span className="text-gray-600">{formatDistance(item.distance_meters)}</span>
</li>
))}
{unit.incidents.length > 3 && (
<li className="text-xs text-gray-500 mt-1">
+ {unit.incidents.length - 3} more incidents
</li>
)}
</ul>
</div>
))}
</div>
)}
</div>
)
}

View File

@ -8,7 +8,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/app/_components/ui/t
import { Button } from "@/app/_components/ui/button"
import { getMonthName } from "@/app/_utils/common"
import { BarChart, Users, Home, AlertTriangle, ChevronRight, Building, Calendar, X } from 'lucide-react'
import type { DistrictFeature } from "../layers/district-layer"
import { IDistrictFeature } from "@/app/_utils/types/map"
// Helper function to format numbers
function formatNumber(num?: number): string {
@ -29,7 +29,7 @@ interface DistrictPopupProps {
longitude: number
latitude: number
onClose: () => void
district: DistrictFeature
district: IDistrictFeature
year?: string
month?: string
filterCategory?: string | "all"

View File

@ -0,0 +1,180 @@
"use client"
import { Popup } from "react-map-gl/mapbox"
import { Badge } from "@/app/_components/ui/badge"
import { Card } from "@/app/_components/ui/card"
import { Separator } from "@/app/_components/ui/separator"
import { Button } from "@/app/_components/ui/button"
import { MapPin, AlertTriangle, Calendar, Clock, Bookmark, Navigation, X, FileText } from "lucide-react"
import { IDistanceResult } from "@/app/_utils/types/crimes"
import { ScrollArea } from "@/app/_components/ui/scroll-area"
import { Skeleton } from "@/app/_components/ui/skeleton"
interface IncidentPopupProps {
longitude: number
latitude: number
onClose: () => void
incident: {
id: string
category?: string
description?: string
date?: Date | string
district?: string
district_id?: string
}
distances?: IDistanceResult[]
isLoadingDistances?: boolean
}
export default function IncidentPopup({
longitude,
latitude,
onClose,
incident,
distances = [],
isLoadingDistances = false
}: IncidentPopupProps) {
const formatDate = (date?: Date | string) => {
if (!date) return "Unknown date"
return new Date(date).toLocaleDateString()
}
const formatTime = (date?: Date | string) => {
if (!date) return "Unknown time"
return new Date(date).toLocaleTimeString()
}
// Format distance to be more readable
const formatDistance = (meters: number) => {
if (meters < 1000) {
return `${meters.toFixed(0)} m`;
} else {
return `${(meters / 1000).toFixed(2)} km`;
}
}
return (
<Popup
longitude={longitude}
latitude={latitude}
closeButton={false}
closeOnClick={false}
onClose={onClose}
anchor="top"
maxWidth="320px"
className="incident-popup z-50"
>
<Card
className="bg-background p-0 w-full max-w-[320px] shadow-xl border-0 overflow-hidden border-l-4 border-l-red-600"
>
<div className="p-4 relative">
{/* Custom close button */}
<Button
variant="ghost"
size="icon"
className="absolute top-2 right-2 h-6 w-6 rounded-full bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700"
onClick={onClose}
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</Button>
<div className="flex items-center justify-between mb-3">
<h3 className="font-bold text-base flex items-center gap-1.5">
<AlertTriangle className="h-4 w-4 text-red-500" />
{incident.category || "Unknown Incident"}
</h3>
</div>
{incident.description && (
<div className="mb-3 bg-slate-50 dark:bg-slate-900/40 p-3 rounded-lg">
<p className="text-sm">
<FileText className="inline-block h-3.5 w-3.5 mr-1.5 align-text-top text-slate-500" />
{incident.description}
</p>
</div>
)}
<Separator className="my-3" />
<div className="grid grid-cols-2 gap-2 text-sm">
{incident.district && (
<div className="col-span-2">
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">District</p>
<p className="flex items-center">
<Bookmark className="inline-block h-3.5 w-3.5 mr-1.5 shrink-0 text-purple-500" />
<span className="font-medium">{incident.district}</span>
</p>
</div>
)}
{incident.date && (
<>
<div>
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">Date</p>
<p className="flex items-center">
<Calendar className="inline-block h-3.5 w-3.5 mr-1.5 shrink-0 text-blue-500" />
<span className="font-medium">{formatDate(incident.date)}</span>
</p>
</div>
<div>
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">Time</p>
<p className="flex items-center">
<Clock className="inline-block h-3.5 w-3.5 mr-1.5 shrink-0 text-amber-500" />
<span className="font-medium">{formatTime(incident.date)}</span>
</p>
</div>
</>
)}
</div>
{/* Distances to police units section */}
<Separator className="my-3" />
<div>
<h4 className="text-sm font-medium mb-2">Nearby Police Units</h4>
{isLoadingDistances ? (
<div className="space-y-2">
<Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-full" />
</div>
) : distances.length > 0 ? (
<ScrollArea className="h-[120px] rounded-md border p-2">
<div className="space-y-2">
{distances.map((item) => (
<div key={item.unit_code} className="flex justify-between items-center text-xs border-b pb-1">
<div>
<p className="font-medium">{item.unit_name || "Unknown Unit"}</p>
<p className="text-muted-foreground text-[10px]">
{item.unit_type || "Police Unit"}
</p>
</div>
<Badge variant="outline" className="ml-2">
{formatDistance(item.distance_meters)}
</Badge>
</div>
))}
</div>
</ScrollArea>
) : (
<p className="text-xs text-muted-foreground text-center p-2">
No police units data available
</p>
)}
</div>
<div className="mt-3 pt-3 border-t border-border">
<p className="text-xs text-muted-foreground flex items-center">
<Navigation className="inline-block h-3 w-3 mr-1 shrink-0" />
Coordinates: {latitude.toFixed(6)}, {longitude.toFixed(6)}
</p>
<p className="text-xs text-muted-foreground mt-1">ID: {incident.id}</p>
</div>
</div>
</Card>
</Popup>
)
}

View File

@ -0,0 +1,167 @@
"use client"
import { Popup } from "react-map-gl/mapbox"
import { Badge } from "@/app/_components/ui/badge"
import { Card } from "@/app/_components/ui/card"
import { Separator } from "@/app/_components/ui/separator"
import { Button } from "@/app/_components/ui/button"
import { Phone, Building, MapPin, Navigation, X, Shield, Compass, Map, Building2 } from "lucide-react"
import { IDistanceResult } from "@/app/_utils/types/crimes"
import { ScrollArea } from "@/app/_components/ui/scroll-area"
import { Skeleton } from "@/app/_components/ui/skeleton"
interface UnitPopupProps {
longitude: number
latitude: number
onClose: () => void
unit: {
id: string
name: string
type?: string
address?: string
phone?: string
district?: string
district_id?: string
}
distances?: IDistanceResult[]
isLoadingDistances?: boolean
}
export default function UnitPopup({
longitude,
latitude,
onClose,
unit,
distances = [],
isLoadingDistances = false
}: UnitPopupProps) {
// Format distance to be more readable
const formatDistance = (meters: number) => {
if (meters < 1000) {
return `${meters.toFixed(0)} m`;
} else {
return `${(meters / 1000).toFixed(2)} km`;
}
}
return (
<Popup
longitude={longitude}
latitude={latitude}
closeButton={false}
closeOnClick={false}
onClose={onClose}
anchor="top"
maxWidth="320px"
className="unit-popup z-50"
>
<Card
className="bg-background p-0 w-full max-w-[320px] shadow-xl border-0 overflow-hidden border-l-4 border-l-blue-700"
>
<div className="p-4 relative">
{/* Custom close button */}
<Button
variant="ghost"
size="icon"
className="absolute top-2 right-2 h-6 w-6 rounded-full bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700"
onClick={onClose}
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</Button>
<div className="flex items-center justify-between mb-3">
<h3 className="font-bold text-base flex items-center gap-1.5">
<Shield className="h-4 w-4 text-blue-700" />
{unit.name || "Police Unit"}
</h3>
<Badge variant="outline" className="bg-blue-100 text-blue-800 border-blue-200">
{unit.type || "Unit"}
</Badge>
</div>
<div className="grid grid-cols-1 gap-2 text-sm">
{unit.address && (
<div>
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">Address</p>
<p className="flex items-center">
<MapPin className="inline-block h-3.5 w-3.5 mr-1.5 shrink-0 text-red-500" />
<span className="font-medium">{unit.address}</span>
</p>
</div>
)}
{unit.phone && (
<div>
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">Contact</p>
<p className="flex items-center">
<Phone className="inline-block h-3.5 w-3.5 mr-1.5 shrink-0 text-green-500" />
<span className="font-medium">{unit.phone}</span>
</p>
</div>
)}
{unit.district && (
<div>
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">District</p>
<p className="flex items-center">
<Building2 className="inline-block h-3.5 w-3.5 mr-1.5 shrink-0 text-purple-500" />
<span className="font-medium">{unit.district}</span>
</p>
</div>
)}
</div>
{/* Distances to incidents section */}
<Separator className="my-3" />
<div>
<h4 className="text-sm font-medium mb-2 flex items-center">
<Compass className="h-4 w-4 mr-1.5 text-blue-600" />
Nearby Incidents
</h4>
{isLoadingDistances ? (
<div className="space-y-2">
<Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-full" />
</div>
) : distances.length > 0 ? (
<ScrollArea className="h-[120px] rounded-md border p-2">
<div className="space-y-2">
{distances.map((item) => (
<div key={item.incident_id} className="flex justify-between items-center text-xs border-b pb-1">
<div>
<p className="font-medium">{item.category_name || "Unknown"}</p>
<p className="text-muted-foreground text-[10px] truncate" style={{ maxWidth: "160px" }}>
{item.incident_description || "No description"}
</p>
</div>
<Badge variant="outline" className="ml-2 whitespace-nowrap">
{formatDistance(item.distance_meters)}
</Badge>
</div>
))}
</div>
</ScrollArea>
) : (
<p className="text-xs text-muted-foreground text-center p-2">
No incident data available
</p>
)}
</div>
<div className="mt-3 pt-3 border-t border-border">
<p className="text-xs text-muted-foreground flex items-center">
<Navigation className="inline-block h-3 w-3 mr-1 shrink-0" />
Coordinates: {latitude.toFixed(6)}, {longitude.toFixed(6)}
</p>
<p className="text-xs text-muted-foreground mt-1">ID: {unit.id}</p>
</div>
</div>
</Card>
</Popup>
)
}

File diff suppressed because one or more lines are too long

View File

@ -15,9 +15,11 @@ import { districtsGeoJson } from '../../prisma/data/geojson/jember/districts';
// Used to track generated IDs
const usedIdRegistry = new Set<string>();
// Add type definition for global counter
// Add type definition for global counter and registry
declare global {
var __idCounter: number;
var __idRegistry: Record<string, string>;
}
/**
@ -392,30 +394,12 @@ function formatDateV2(date: Date, formatStr: string): string {
* @param {boolean} options.upperCase - Convert result to uppercase
* @returns {string} - Generated custom ID
*/
/**
* Universal Custom ID Generator
* Creates structured, readable IDs for any system or entity
*
* @param {Object} options - Configuration options
* @param {string} options.prefix - Primary identifier prefix (e.g., "CRIME", "USER", "INVOICE")
* @param {Object} options.segments - Collection of ID segments to include
* @param {string[]} options.segments.codes - Array of short codes (e.g., region codes, department codes)
* @param {number} options.segments.year - Year to include in the ID
* @param {number} options.segments.sequentialDigits - Number of digits for sequential number
* @param {boolean} options.segments.includeDate - Whether to include current date
* @param {string} options.segments.dateFormat - Format for date (e.g., "yyyy-MM-dd", "dd/MM/yyyy")
* @param {boolean} options.segments.includeTime - Whether to include timestamp
* @param {string} options.format - Custom format string for ID structure
* @param {string} options.separator - Character to separate ID components
* @param {boolean} options.upperCase - Convert result to uppercase
* @returns {string} - Generated custom ID
*/
export function generateId(
export async function generateId(
options: {
prefix?: string;
segments?: {
codes?: string[];
year?: number | boolean; // Year diubah menjadi number | boolean
year?: number | boolean;
sequentialDigits?: number;
includeDate?: boolean;
dateFormat?: string;
@ -429,10 +413,10 @@ export function generateId(
uniquenessStrategy?: 'uuid' | 'timestamp' | 'counter' | 'hash';
retryOnCollision?: boolean;
maxRetries?: number;
storage?: 'memory' | 'localStorage' | 'database';
tableName?: string; // Added table name for database interactions
} = {}
): string {
// Jika uniquenessStrategy tidak diatur dan randomSequence = false,
// gunakan counter sebagai strategi default
): Promise<string> {
if (!options.uniquenessStrategy && options.randomSequence === false) {
options.uniquenessStrategy = 'counter';
}
@ -441,7 +425,7 @@ export function generateId(
prefix: options.prefix || 'ID',
segments: {
codes: options.segments?.codes || [],
year: options.segments?.year, // Akan diproses secara kondisional nanti
year: options.segments?.year,
sequentialDigits: options.segments?.sequentialDigits || 6,
includeDate: options.segments?.includeDate ?? false,
dateFormat: options.segments?.dateFormat || 'yyyyMMdd',
@ -455,22 +439,21 @@ export function generateId(
uniquenessStrategy: options.uniquenessStrategy || 'timestamp',
retryOnCollision: options.retryOnCollision ?? true,
maxRetries: options.maxRetries || 10,
storage: options.storage || 'memory',
tableName: options.tableName
};
// Initialize global counter if not exists
if (typeof globalThis.__idCounter === 'undefined') {
globalThis.__idCounter = 0;
}
const now = new Date();
// Generate date string if needed
let dateString = '';
if (config.segments.includeDate) {
dateString = format(now, config.segments.dateFormat);
}
// Generate time string if needed
let timeString = '';
if (config.segments.includeTime) {
timeString = format(now, 'HHmmss');
@ -479,7 +462,6 @@ export function generateId(
}
}
// Generate sequential number based on uniqueness strategy
let sequentialNum: string;
try {
switch (config.uniquenessStrategy) {
@ -491,9 +473,34 @@ export function generateId(
sequentialNum = sequentialNum.slice(-config.segments.sequentialDigits);
break;
case 'counter':
sequentialNum = (++globalThis.__idCounter)
.toString()
.padStart(config.segments.sequentialDigits, '0');
const lastId = await getLastId(config.prefix, {
separator: config.separator,
storage: config.storage,
tableName: config.tableName
});
let counterStart = 0;
if (lastId !== null) {
const parts = lastId.split(config.separator);
const lastPart = parts[parts.length - 1];
if (/^\d+$/.test(lastPart)) {
counterStart = parseInt(lastPart, 10);
} else {
for (let i = parts.length - 1; i >= 0; i--) {
if (/^\d+$/.test(parts[i])) {
counterStart = parseInt(parts[i], 10);
break;
}
}
}
}
const nextCounter = counterStart + 1;
globalThis.__idCounter = nextCounter;
sequentialNum = nextCounter.toString().padStart(config.segments.sequentialDigits, '0');
break;
case 'hash':
const hashSource = `${now.getTime()}-${JSON.stringify(options)}-${Math.random()}`;
@ -513,39 +520,59 @@ export function generateId(
.toString()
.padStart(config.segments.sequentialDigits, '0');
} else {
sequentialNum = (++globalThis.__idCounter)
.toString()
.padStart(config.segments.sequentialDigits, '0');
const lastId = await getLastId(config.prefix, {
separator: config.separator,
storage: config.storage,
tableName: config.tableName
});
let counterStart = 0;
if (lastId !== null) {
const parts = lastId.split(config.separator);
const lastPart = parts[parts.length - 1];
if (/^\d+$/.test(lastPart)) {
counterStart = parseInt(lastPart, 10);
} else {
for (let i = parts.length - 1; i >= 0; i--) {
if (/^\d+$/.test(parts[i])) {
counterStart = parseInt(parts[i], 10);
break;
}
}
}
}
const nextCounter = counterStart + 1;
globalThis.__idCounter = nextCounter;
sequentialNum = nextCounter.toString().padStart(config.segments.sequentialDigits, '0');
}
}
} catch (error) {
console.error('Error generating sequential number:', error);
// Fallback to timestamp strategy if other methods fail
sequentialNum = `${now.getTime()}`.slice(-config.segments.sequentialDigits);
}
// Determine if year should be included and what value to use
let yearValue = null;
if (config.segments.year !== undefined || config.segments.year != false) {
if (config.segments.year !== undefined && config.segments.year !== false) {
if (typeof config.segments.year === 'number') {
yearValue = String(config.segments.year);
} else if (config.segments.year === true) {
yearValue = format(now, 'yyyy');
}
// if year is false, yearValue remains null and won't be included
} else {
// Default behavior (backward compatibility)
yearValue = format(now, 'yyyy');
}
// Prepare components for ID assembly
const components = {
prefix: config.prefix,
codes:
config.segments.codes.length > 0
? config.segments.codes.join(config.separator)
: '',
year: yearValue, // Added the year value to components
year: yearValue,
sequence: sequentialNum,
date: dateString,
time: timeString,
@ -553,7 +580,6 @@ export function generateId(
let result: string;
// Use custom format if provided
if (config.format) {
let customID = config.format;
for (const [key, value] of Object.entries(components)) {
@ -565,10 +591,8 @@ export function generateId(
);
}
}
// Remove unused placeholders
customID = customID.replace(/{[^}]+}/g, '');
// Clean up separators
const escapedSeparator = config.separator.replace(
/[-\/\\^$*+?.()|[\]{}]/g,
'\\$&'
@ -582,7 +606,6 @@ export function generateId(
result = config.upperCase ? customID.toUpperCase() : customID;
} else {
// Assemble ID from parts
const parts = [];
if (components.prefix) parts.push(components.prefix);
if (components.codes) parts.push(components.codes);
@ -595,7 +618,6 @@ export function generateId(
if (config.upperCase) result = result.toUpperCase();
}
// Handle collisions if required
if (config.retryOnCollision) {
let retryCount = 0;
let originalResult = result;
@ -607,7 +629,6 @@ export function generateId(
result = `${originalResult}${config.separator}${suffix}`;
} catch (error) {
console.error('Error generating collision suffix:', error);
// Simple fallback if crypto fails
result = `${originalResult}${config.separator}${Date.now().toString(36)}`;
}
}
@ -619,7 +640,6 @@ export function generateId(
}
}
// Register the ID and maintain registry size
usedIdRegistry.add(result);
if (usedIdRegistry.size > 10000) {
const entriesToKeep = Array.from(usedIdRegistry).slice(-5000);
@ -627,31 +647,161 @@ export function generateId(
entriesToKeep.forEach((id) => usedIdRegistry.add(id));
}
await updateLastId(config.prefix, result, {
storage: config.storage,
tableName: config.tableName
});
return result.trim();
}
/**
* Gets the last ID from a specified table and column.
* @param tableName - The name of the table to query.
* @param columnName - The column containing the IDs.
* @returns The last ID as a string, or null if no records exist.
* Retrieves the last generated ID for a specific prefix
* Used by generateId to determine the next sequential number
*
* @param {string} prefix - The prefix to look up (e.g., "INVOICE", "USER")
* @param {Object} options - Additional options
* @param {string} options.separator - The separator used in IDs (must match generateId)
* @param {boolean} options.extractFullSequence - Whether to extract the full sequence or just the numeric part
* @param {string} options.storage - Storage method ('memory', 'localStorage', 'database')
* @returns {string|null} - Returns the last ID or null if none exists
*/
export async function getLastId(
tableName: string,
columnName: string
prefix: string,
options: {
separator?: string;
extractFullSequence?: boolean;
storage?: 'memory' | 'localStorage' | 'database';
tableName?: string;
} = {}
): Promise<string | null> {
try {
const result = await db.$queryRawUnsafe(
`SELECT ${columnName} FROM ${tableName} ORDER BY ${columnName} DESC LIMIT 1`
);
const config = {
separator: options.separator || '-',
extractFullSequence: options.extractFullSequence ?? false,
storage: options.storage || 'memory',
tableName: options.tableName
};
if (Array.isArray(result) && result.length > 0) {
return result[0][columnName];
}
} catch (error) {
console.error('Error fetching last ID:', error);
if (typeof globalThis.__idRegistry === 'undefined') {
globalThis.__idRegistry = {};
}
return null;
const normalizedPrefix = prefix.toUpperCase();
let lastId: string | null = null;
switch (config.storage) {
case 'localStorage':
try {
const storedIds = localStorage.getItem('customIdRegistry');
if (storedIds) {
const registry = JSON.parse(storedIds);
lastId = registry[normalizedPrefix] || null;
}
} catch (error) {
console.error('Error accessing localStorage:', error);
lastId = globalThis.__idRegistry[normalizedPrefix] || null;
}
break;
case 'database':
if (!config.tableName) {
console.warn('Table name not provided for database storage. Falling back to memory storage.');
lastId = globalThis.__idRegistry[normalizedPrefix] || null;
break;
}
try {
const result = await db.$queryRawUnsafe<{id: string}[]>(
`SELECT id FROM ${config.tableName}
WHERE id LIKE $1
ORDER BY id DESC
LIMIT 1`,
`${normalizedPrefix}%`
);
if (result && result.length > 0) {
lastId = result[0].id;
}
} catch (error) {
console.error(`Error querying database for last ID in ${config.tableName}:`, error);
lastId = globalThis.__idRegistry[normalizedPrefix] || null;
}
break;
case 'memory':
default:
lastId = globalThis.__idRegistry[normalizedPrefix] || null;
break;
}
return lastId;
}
/**
* Updates the last ID record for a specific prefix
* Should be called by generateId after creating a new ID
*
* @param {string} prefix - The prefix to update
* @param {string} id - The newly generated ID to store
* @param {Object} options - Additional options (matching getLastId options)
*/
export async function updateLastId(
prefix: string,
id: string,
options: {
storage?: 'memory' | 'localStorage' | 'database';
tableName?: string;
} = {}
): Promise<void> {
const config = {
storage: options.storage || 'memory',
tableName: options.tableName
};
if (typeof globalThis.__idRegistry === 'undefined') {
globalThis.__idRegistry = {};
}
const normalizedPrefix = prefix.toUpperCase();
switch (config.storage) {
case 'localStorage':
try {
let registry: Record<string, string> = {};
const storedIds = localStorage.getItem('customIdRegistry');
if (storedIds) {
registry = JSON.parse(storedIds) as Record<string, string>;
}
registry[normalizedPrefix] = id;
localStorage.setItem('customIdRegistry', JSON.stringify(registry));
globalThis.__idRegistry[normalizedPrefix] = id;
} catch (error) {
console.error('Error updating localStorage:', error);
globalThis.__idRegistry[normalizedPrefix] = id;
}
break;
case 'database':
globalThis.__idRegistry[normalizedPrefix] = id;
if (config.tableName) {
try {
// Optional: Update a dedicated ID tracking table if you have one
} catch (error) {
console.error(`Error updating ID registry in database:`, error);
}
}
break;
case 'memory':
default:
globalThis.__idRegistry[normalizedPrefix] = id;
break;
}
}
/**
@ -777,7 +927,6 @@ export const getDistrictName = (districtId: string): string => {
export function formatNumber(num?: number): string {
if (num === undefined || num === null) return "N/A";
// If number is in the thousands, abbreviate
if (num >= 1_000_000) {
return (num / 1_000_000).toFixed(1) + 'M';
}
@ -786,7 +935,6 @@ export function formatNumber(num?: number): string {
return (num / 1_000).toFixed(1) + 'K';
}
// Otherwise, format with commas
return num.toLocaleString();
}

View File

@ -250,3 +250,16 @@ export const processDistrictFeature = (
isFocused: true,
};
};
/**
* Format distance in a human-readable way
* @param meters Distance in meters
* @returns Formatted distance string
*/
export function formatDistance(meters: number): string {
if (meters < 1000) {
return `${Math.round(meters)} m`;
} else {
return `${(meters / 1000).toFixed(1)} km`;
}
}

View File

@ -76,3 +76,18 @@ export interface ICrimesByYearAndMonth extends crimes {
};
}[];
}
export interface IDistanceResult {
unit_code: string;
unit_name: string;
unit_type: string;
unit_lat: number;
unit_lng: number;
incident_id: number;
incident_description: string;
incident_lat: number;
incident_lng: number;
category_name: string;
district_name: string;
distance_meters: number;
}

View File

@ -60,6 +60,8 @@
"recharts": "^2.15.2",
"resend": "^4.1.2",
"sonner": "^2.0.1",
"three": "^0.176.0",
"threebox-plugin": "^2.2.7",
"uuid": "^11.1.0",
"vaul": "^1.1.2",
"zod": "^3.24.2",
@ -72,6 +74,7 @@
"@types/node": "^22.10.2",
"@types/react": "^19.0.2",
"@types/react-dom": "19.0.2",
"@types/three": "^0.176.0",
"@types/uuid": "^10.0.0",
"postcss": "8.4.49",
"prisma": "^6.4.1",
@ -429,6 +432,13 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@dimforge/rapier3d-compat": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@emnapi/runtime": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.3.1.tgz",
@ -7207,6 +7217,13 @@
"url": "https://opencollective.com/turf"
}
},
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/cacheable-request": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
@ -7506,6 +7523,13 @@
"integrity": "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==",
"license": "MIT"
},
"node_modules/@types/stats.js": {
"version": "0.17.3",
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.3.tgz",
"integrity": "sha512-pXNfAD3KHOdif9EQXZ9deK82HVNaXP5ZIF5RP2QG6OQFNTaY2YIetfrE9t528vEreGQvEPRDDc8muaoYeK0SxQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/supercluster": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
@ -7524,6 +7548,22 @@
"@types/node": "*"
}
},
"node_modules/@types/three": {
"version": "0.176.0",
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.176.0.tgz",
"integrity": "sha512-FwfPXxCqOtP7EdYMagCFePNKoG1AGBDUEVKtluv2BTVRpSt7b+X27xNsirPCTCqY1pGYsPUzaM3jgWP7dXSxlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@dimforge/rapier3d-compat": "^0.12.0",
"@tweenjs/tween.js": "~23.1.3",
"@types/stats.js": "*",
"@types/webxr": "*",
"@webgpu/types": "*",
"fflate": "~0.8.2",
"meshoptimizer": "~0.18.1"
}
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
@ -7531,6 +7571,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/webxr": {
"version": "0.5.22",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.22.tgz",
"integrity": "sha512-Vr6Stjv5jPRqH690f5I5GLjVk8GSsoQSYJ2FVd/3jJF7KaqfwPi3ehfBS96mlQ2kPCwZaX6U0rG2+NGHBKkA/A==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.14.tgz",
@ -7850,6 +7897,13 @@
"@xtuc/long": "4.2.2"
}
},
"node_modules/@webgpu/types": {
"version": "0.1.60",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.60.tgz",
"integrity": "sha512-8B/tdfRFKdrnejqmvq95ogp8tf52oZ51p3f4QD5m5Paey/qlX4Rhhy5Y8tgFMi7Ms70HzcMMw3EQjH/jdhTwlA==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@xtuc/ieee754": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
@ -9960,6 +10014,13 @@
"reusify": "^1.0.4"
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"dev": true,
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@ -11270,6 +11331,13 @@
"node": ">= 8"
}
},
"node_modules/meshoptimizer": {
"version": "0.18.1",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz",
"integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==",
"dev": true,
"license": "MIT"
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
@ -14824,6 +14892,18 @@
"node": ">=0.8"
}
},
"node_modules/three": {
"version": "0.176.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.176.0.tgz",
"integrity": "sha512-PWRKYWQo23ojf9oZSlRGH8K09q7nRSWx6LY/HF/UUrMdYgN9i1e2OwJYHoQjwc6HF/4lvvYLC5YC1X8UJL2ZpA==",
"license": "MIT"
},
"node_modules/threebox-plugin": {
"version": "2.2.7",
"resolved": "https://registry.npmjs.org/threebox-plugin/-/threebox-plugin-2.2.7.tgz",
"integrity": "sha512-H87Nm4w1PfisHPHzavTGXlwIoJpx2+QU57GooQYIhF51lsg+U5A0KGf3Jrv/HWsLCGOwV2BTnv7UTLfpO1EccQ==",
"license": "MIT"
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",

View File

@ -66,6 +66,8 @@
"recharts": "^2.15.2",
"resend": "^4.1.2",
"sonner": "^2.0.1",
"three": "^0.176.0",
"threebox-plugin": "^2.2.7",
"uuid": "^11.1.0",
"vaul": "^1.1.2",
"zod": "^3.24.2",
@ -78,6 +80,7 @@
"@types/node": "^22.10.2",
"@types/react": "^19.0.2",
"@types/react-dom": "19.0.2",
"@types/three": "^0.176.0",
"@types/uuid": "^10.0.0",
"postcss": "8.4.49",
"prisma": "^6.4.1",

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,159 @@
-- Migration to recreate the prisma user with proper privileges
-- First, drop the existing prisma role if it exists and recreate it
-- Drop the role if it exists
DO $$
BEGIN
IF EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'prisma') THEN
DROP ROLE prisma;
END IF;
END
$$;
-- Create the prisma role with login capability
CREATE ROLE prisma WITH LOGIN PASSWORD 'prisma';
-- -- Grant usage on all necessary schemas
-- GRANT USAGE ON SCHEMA public TO prisma;
-- GRANT USAGE ON SCHEMA gis TO prisma;
-- GRANT USAGE ON SCHEMA auth TO prisma;
-- GRANT USAGE ON SCHEMA storage TO prisma;
-- GRANT USAGE ON SCHEMA graphql TO prisma;
-- GRANT USAGE ON SCHEMA extensions TO prisma;
-- -- Explicitly grant permissions on auth and storage schemas
-- DO $$
-- BEGIN
-- -- Explicitly grant on auth schema
-- EXECUTE 'GRANT USAGE ON SCHEMA auth TO prisma';
-- -- Explicitly grant on storage schema
-- EXECUTE 'GRANT USAGE ON SCHEMA storage TO prisma';
-- END
-- $$;
-- -- Grant privileges on all tables in schemas
-- DO $$
-- DECLARE
-- r RECORD;
-- BEGIN
-- -- Grant privileges on all tables in public schema
-- FOR r IN SELECT tablename FROM pg_tables WHERE schemaname = 'public' LOOP
-- EXECUTE 'GRANT ALL PRIVILEGES ON TABLE public.' || quote_ident(r.tablename) || ' TO prisma';
-- END LOOP;
-- -- Grant privileges on all tables in gis schema
-- FOR r IN SELECT tablename FROM pg_tables WHERE schemaname = 'gis' LOOP
-- EXECUTE 'GRANT ALL PRIVILEGES ON TABLE gis.' || quote_ident(r.tablename) || ' TO prisma';
-- END LOOP;
-- -- Grant privileges on all tables in auth schema
-- FOR r IN SELECT tablename FROM pg_tables WHERE schemaname = 'auth' LOOP
-- EXECUTE 'GRANT SELECT, DELETE ON TABLE auth.' || quote_ident(r.tablename) || ' TO prisma';
-- END LOOP;
-- -- Grant privileges on all tables in storage schema
-- FOR r IN SELECT tablename FROM pg_tables WHERE schemaname = 'storage' LOOP
-- EXECUTE 'GRANT SELECT, DELETE ON TABLE storage.' || quote_ident(r.tablename) || ' TO prisma';
-- END LOOP;
-- -- Grant privileges on all sequences in public schema
-- FOR r IN SELECT sequence_name FROM information_schema.sequences WHERE sequence_schema = 'public' LOOP
-- EXECUTE 'GRANT ALL PRIVILEGES ON SEQUENCE public.' || quote_ident(r.sequence_name) || ' TO prisma';
-- END LOOP;
-- -- Grant privileges on all sequences in gis schema
-- FOR r IN SELECT sequence_name FROM information_schema.sequences WHERE sequence_schema = 'gis' LOOP
-- EXECUTE 'GRANT ALL PRIVILEGES ON SEQUENCE gis.' || quote_ident(r.sequence_name) || ' TO prisma';
-- END LOOP;
-- -- Grant privileges on all sequences in auth schema
-- FOR r IN SELECT sequence_name FROM information_schema.sequences WHERE sequence_schema = 'auth' LOOP
-- EXECUTE 'GRANT USAGE ON SEQUENCE auth.' || quote_ident(r.sequence_name) || ' TO prisma';
-- END LOOP;
-- -- Grant privileges on all sequences in storage schema
-- FOR r IN SELECT sequence_name FROM information_schema.sequences WHERE sequence_schema = 'storage' LOOP
-- EXECUTE 'GRANT USAGE ON SEQUENCE storage.' || quote_ident(r.sequence_name) || ' TO prisma';
-- END LOOP;
-- -- Grant usage on all types in public schema
-- EXECUTE 'GRANT USAGE ON TYPE "public"."crime_rates" TO prisma';
-- EXECUTE 'GRANT USAGE ON TYPE "public"."crime_status" TO prisma';
-- EXECUTE 'GRANT USAGE ON TYPE "public"."session_status" TO prisma';
-- EXECUTE 'GRANT USAGE ON TYPE "public"."status_contact_messages" TO prisma';
-- EXECUTE 'GRANT USAGE ON TYPE "public"."unit_type" TO prisma';
-- END
-- $$;
-- -- Grant execute privileges on functions (separate DO block to avoid EXCEPTION issues)
-- DO $$
-- DECLARE
-- r RECORD;
-- BEGIN
-- -- Grant execute privileges on all functions in public schema
-- FOR r IN SELECT routines.routine_name
-- FROM information_schema.routines
-- WHERE routines.specific_schema = 'public'
-- AND routines.routine_type = 'FUNCTION' LOOP
-- BEGIN
-- EXECUTE 'GRANT EXECUTE ON FUNCTION public.' || quote_ident(r.routine_name) || '() TO prisma';
-- EXCEPTION WHEN OTHERS THEN
-- RAISE NOTICE 'Error granting execute on function public.%: %', r.routine_name, SQLERRM;
-- END;
-- END LOOP;
-- END
-- $$;
-- -- Handle gis functions in a separate block
-- DO $$
-- DECLARE
-- r RECORD;
-- BEGIN
-- -- Grant execute privileges on all functions in gis schema
-- FOR r IN SELECT routines.routine_name
-- FROM information_schema.routines
-- WHERE routines.specific_schema = 'gis'
-- AND routines.routine_type = 'FUNCTION' LOOP
-- BEGIN
-- EXECUTE 'GRANT EXECUTE ON FUNCTION gis.' || quote_ident(r.routine_name) || '() TO prisma';
-- EXCEPTION WHEN OTHERS THEN
-- RAISE NOTICE 'Error granting execute on function gis.%: %', r.routine_name, SQLERRM;
-- END;
-- END LOOP;
-- END
-- $$;
-- -- Set default privileges for future objects
-- ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO prisma;
-- ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO prisma;
-- ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON FUNCTIONS TO prisma;
-- ALTER DEFAULT PRIVILEGES IN SCHEMA gis GRANT ALL ON TABLES TO prisma;
-- ALTER DEFAULT PRIVILEGES IN SCHEMA gis GRANT ALL ON SEQUENCES TO prisma;
-- ALTER DEFAULT PRIVILEGES IN SCHEMA gis GRANT ALL ON FUNCTIONS TO prisma;
-- ALTER DEFAULT PRIVILEGES IN SCHEMA auth GRANT SELECT, DELETE ON TABLES TO prisma;
-- ALTER DEFAULT PRIVILEGES IN SCHEMA storage GRANT SELECT, DELETE ON TABLES TO prisma;
-- -- Ensure the prisma role has the necessary permissions for the auth schema triggers
-- DO $$
-- BEGIN
-- EXECUTE 'GRANT EXECUTE ON FUNCTION public.handle_new_user() TO prisma';
-- EXCEPTION WHEN OTHERS THEN
-- RAISE NOTICE 'Error granting execute on function public.handle_new_user(): %', SQLERRM;
-- END $$;
-- DO $$
-- BEGIN
-- EXECUTE 'GRANT EXECUTE ON FUNCTION public.handle_user_delete() TO prisma';
-- EXCEPTION WHEN OTHERS THEN
-- RAISE NOTICE 'Error granting execute on function public.handle_user_delete(): %', SQLERRM;
-- END $$;
-- DO $$
-- BEGIN
-- EXECUTE 'GRANT EXECUTE ON FUNCTION public.handle_user_update() TO prisma';
-- EXCEPTION WHEN OTHERS THEN
-- RAISE NOTICE 'Error granting execute on function public.handle_user_update(): %', SQLERRM;
-- END $$;
-- -- Grant postgres user the ability to manage prisma role
-- GRANT prisma TO postgres;

View File

@ -0,0 +1,327 @@
grant delete on table "auth"."audit_log_entries" to "prisma";
grant select on table "auth"."audit_log_entries" to "prisma";
grant delete on table "auth"."flow_state" to "prisma";
grant select on table "auth"."flow_state" to "prisma";
grant delete on table "auth"."identities" to "prisma";
grant select on table "auth"."identities" to "prisma";
grant delete on table "auth"."instances" to "prisma";
grant select on table "auth"."instances" to "prisma";
grant delete on table "auth"."mfa_amr_claims" to "prisma";
grant select on table "auth"."mfa_amr_claims" to "prisma";
grant delete on table "auth"."mfa_challenges" to "prisma";
grant select on table "auth"."mfa_challenges" to "prisma";
grant delete on table "auth"."mfa_factors" to "prisma";
grant select on table "auth"."mfa_factors" to "prisma";
grant delete on table "auth"."one_time_tokens" to "prisma";
grant select on table "auth"."one_time_tokens" to "prisma";
grant delete on table "auth"."refresh_tokens" to "prisma";
grant select on table "auth"."refresh_tokens" to "prisma";
grant delete on table "auth"."saml_providers" to "prisma";
grant select on table "auth"."saml_providers" to "prisma";
grant delete on table "auth"."saml_relay_states" to "prisma";
grant select on table "auth"."saml_relay_states" to "prisma";
grant delete on table "auth"."schema_migrations" to "prisma";
grant select on table "auth"."schema_migrations" to "prisma";
grant delete on table "auth"."sessions" to "prisma";
grant select on table "auth"."sessions" to "prisma";
grant delete on table "auth"."sso_domains" to "prisma";
grant select on table "auth"."sso_domains" to "prisma";
grant delete on table "auth"."sso_providers" to "prisma";
grant select on table "auth"."sso_providers" to "prisma";
grant delete on table "auth"."users" to "prisma";
grant select on table "auth"."users" to "prisma";
CREATE TRIGGER on_auth_user_created AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION handle_new_user();
CREATE TRIGGER on_auth_user_deleted BEFORE DELETE ON auth.users FOR EACH ROW EXECUTE FUNCTION handle_user_delete();
CREATE TRIGGER on_auth_user_updated AFTER UPDATE ON auth.users FOR EACH ROW EXECUTE FUNCTION handle_user_update();
drop trigger if exists "objects_delete_delete_prefix" on "storage"."objects";
drop trigger if exists "objects_insert_create_prefix" on "storage"."objects";
drop trigger if exists "objects_update_create_prefix" on "storage"."objects";
drop trigger if exists "prefixes_create_hierarchy" on "storage"."prefixes";
drop trigger if exists "prefixes_delete_hierarchy" on "storage"."prefixes";
revoke delete on table "storage"."prefixes" from "anon";
revoke insert on table "storage"."prefixes" from "anon";
revoke references on table "storage"."prefixes" from "anon";
revoke select on table "storage"."prefixes" from "anon";
revoke trigger on table "storage"."prefixes" from "anon";
revoke truncate on table "storage"."prefixes" from "anon";
revoke update on table "storage"."prefixes" from "anon";
revoke delete on table "storage"."prefixes" from "authenticated";
revoke insert on table "storage"."prefixes" from "authenticated";
revoke references on table "storage"."prefixes" from "authenticated";
revoke select on table "storage"."prefixes" from "authenticated";
revoke trigger on table "storage"."prefixes" from "authenticated";
revoke truncate on table "storage"."prefixes" from "authenticated";
revoke update on table "storage"."prefixes" from "authenticated";
revoke delete on table "storage"."prefixes" from "service_role";
revoke insert on table "storage"."prefixes" from "service_role";
revoke references on table "storage"."prefixes" from "service_role";
revoke select on table "storage"."prefixes" from "service_role";
revoke trigger on table "storage"."prefixes" from "service_role";
revoke truncate on table "storage"."prefixes" from "service_role";
revoke update on table "storage"."prefixes" from "service_role";
alter table "storage"."prefixes" drop constraint "prefixes_bucketId_fkey";
drop function if exists "storage"."add_prefixes"(_bucket_id text, _name text);
drop function if exists "storage"."delete_prefix"(_bucket_id text, _name text);
drop function if exists "storage"."delete_prefix_hierarchy_trigger"();
drop function if exists "storage"."get_level"(name text);
drop function if exists "storage"."get_prefix"(name text);
drop function if exists "storage"."get_prefixes"(name text);
drop function if exists "storage"."objects_insert_prefix_trigger"();
drop function if exists "storage"."objects_update_prefix_trigger"();
drop function if exists "storage"."prefixes_insert_trigger"();
drop function if exists "storage"."search_legacy_v1"(prefix text, bucketname text, limits integer, levels integer, offsets integer, search text, sortcolumn text, sortorder text);
drop function if exists "storage"."search_v1_optimised"(prefix text, bucketname text, limits integer, levels integer, offsets integer, search text, sortcolumn text, sortorder text);
drop function if exists "storage"."search_v2"(prefix text, bucket_name text, limits integer, levels integer, start_after text);
alter table "storage"."prefixes" drop constraint "prefixes_pkey";
drop index if exists "storage"."idx_name_bucket_level_unique";
drop index if exists "storage"."idx_objects_lower_name";
drop index if exists "storage"."idx_prefixes_lower_name";
drop index if exists "storage"."objects_bucket_id_level_idx";
drop index if exists "storage"."prefixes_pkey";
drop table "storage"."prefixes";
alter table "storage"."objects" drop column "level";
set check_function_bodies = off;
CREATE OR REPLACE FUNCTION storage.extension(name text)
RETURNS text
LANGUAGE plpgsql
AS $function$
DECLARE
_parts text[];
_filename text;
BEGIN
select string_to_array(name, '/') into _parts;
select _parts[array_length(_parts,1)] into _filename;
-- @todo return the last part instead of 2
return reverse(split_part(reverse(_filename), '.', 1));
END
$function$
;
CREATE OR REPLACE FUNCTION storage.foldername(name text)
RETURNS text[]
LANGUAGE plpgsql
AS $function$
DECLARE
_parts text[];
BEGIN
select string_to_array(name, '/') into _parts;
return _parts[1:array_length(_parts,1)-1];
END
$function$
;
CREATE OR REPLACE FUNCTION storage.get_size_by_bucket()
RETURNS TABLE(size bigint, bucket_id text)
LANGUAGE plpgsql
AS $function$
BEGIN
return query
select sum((metadata->>'size')::int) as size, obj.bucket_id
from "storage".objects as obj
group by obj.bucket_id;
END
$function$
;
CREATE OR REPLACE FUNCTION storage.search(prefix text, bucketname text, limits integer DEFAULT 100, levels integer DEFAULT 1, offsets integer DEFAULT 0, search text DEFAULT ''::text, sortcolumn text DEFAULT 'name'::text, sortorder text DEFAULT 'asc'::text)
RETURNS TABLE(name text, id uuid, updated_at timestamp with time zone, created_at timestamp with time zone, last_accessed_at timestamp with time zone, metadata jsonb)
LANGUAGE plpgsql
STABLE
AS $function$
declare
v_order_by text;
v_sort_order text;
begin
case
when sortcolumn = 'name' then
v_order_by = 'name';
when sortcolumn = 'updated_at' then
v_order_by = 'updated_at';
when sortcolumn = 'created_at' then
v_order_by = 'created_at';
when sortcolumn = 'last_accessed_at' then
v_order_by = 'last_accessed_at';
else
v_order_by = 'name';
end case;
case
when sortorder = 'asc' then
v_sort_order = 'asc';
when sortorder = 'desc' then
v_sort_order = 'desc';
else
v_sort_order = 'asc';
end case;
v_order_by = v_order_by || ' ' || v_sort_order;
return query execute
'with folders as (
select path_tokens[$1] as folder
from storage.objects
where objects.name ilike $2 || $3 || ''%''
and bucket_id = $4
and array_length(objects.path_tokens, 1) <> $1
group by folder
order by folder ' || v_sort_order || '
)
(select folder as "name",
null as id,
null as updated_at,
null as created_at,
null as last_accessed_at,
null as metadata from folders)
union all
(select path_tokens[$1] as "name",
id,
updated_at,
created_at,
last_accessed_at,
metadata
from storage.objects
where objects.name ilike $2 || $3 || ''%''
and bucket_id = $4
and array_length(objects.path_tokens, 1) = $1
order by ' || v_order_by || ')
limit $5
offset $6' using levels, prefix, search, bucketname, limits, offsets;
end;
$function$
;
create policy "Anyone can update their own avatar."
on "storage"."objects"
as permissive
for update
to public
using ((( SELECT auth.uid() AS uid) = owner))
with check ((bucket_id = 'avatars'::text));
create policy "allow all 1oj01fe_0"
on "storage"."objects"
as permissive
for delete
to authenticated
using ((bucket_id = 'avatars'::text));
create policy "allow all 1oj01fe_1"
on "storage"."objects"
as permissive
for update
to authenticated
using ((bucket_id = 'avatars'::text));
create policy "allow all 1oj01fe_2"
on "storage"."objects"
as permissive
for insert
to authenticated
with check ((bucket_id = 'avatars'::text));
create policy "allow all 1oj01fe_3"
on "storage"."objects"
as permissive
for select
to authenticated
using ((bucket_id = 'avatars'::text));
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);

View File

@ -0,0 +1,160 @@
-- Grant usage on all necessary schemas
GRANT USAGE ON SCHEMA public TO prisma;
GRANT USAGE ON SCHEMA gis TO prisma;
GRANT USAGE ON SCHEMA auth TO prisma;
GRANT USAGE ON SCHEMA storage TO prisma;
GRANT USAGE ON SCHEMA graphql TO prisma;
GRANT USAGE ON SCHEMA extensions TO prisma;
-- Explicitly grant permissions on auth and storage schemas
DO $$
BEGIN
-- Explicitly grant on auth schema
EXECUTE 'GRANT USAGE ON SCHEMA auth TO prisma';
-- Explicitly grant on storage schema
EXECUTE 'GRANT USAGE ON SCHEMA storage TO prisma';
END
$$;
-- Grant privileges on all tables in schemas
DO $$
DECLARE
r RECORD;
BEGIN
-- Grant privileges on all tables in public schema
FOR r IN SELECT tablename FROM pg_tables WHERE schemaname = 'public' LOOP
EXECUTE 'GRANT ALL PRIVILEGES ON TABLE public.' || quote_ident(r.tablename) || ' TO prisma';
END LOOP;
-- Grant privileges on all tables in gis schema
FOR r IN SELECT tablename FROM pg_tables WHERE schemaname = 'gis' LOOP
EXECUTE 'GRANT ALL PRIVILEGES ON TABLE gis.' || quote_ident(r.tablename) || ' TO prisma';
END LOOP;
-- Grant privileges on all tables in auth schema
FOR r IN SELECT tablename FROM pg_tables WHERE schemaname = 'auth' LOOP
EXECUTE 'GRANT SELECT, DELETE ON TABLE auth.' || quote_ident(r.tablename) || ' TO prisma';
END LOOP;
-- Grant privileges on all tables in storage schema
FOR r IN SELECT tablename FROM pg_tables WHERE schemaname = 'storage' LOOP
EXECUTE 'GRANT SELECT, DELETE ON TABLE storage.' || quote_ident(r.tablename) || ' TO prisma';
END LOOP;
-- Grant privileges on all sequences in public schema
FOR r IN SELECT sequence_name FROM information_schema.sequences WHERE sequence_schema = 'public' LOOP
EXECUTE 'GRANT ALL PRIVILEGES ON SEQUENCE public.' || quote_ident(r.sequence_name) || ' TO prisma';
END LOOP;
-- Grant privileges on all sequences in gis schema
FOR r IN SELECT sequence_name FROM information_schema.sequences WHERE sequence_schema = 'gis' LOOP
EXECUTE 'GRANT ALL PRIVILEGES ON SEQUENCE gis.' || quote_ident(r.sequence_name) || ' TO prisma';
END LOOP;
-- Grant privileges on all sequences in auth schema
FOR r IN SELECT sequence_name FROM information_schema.sequences WHERE sequence_schema = 'auth' LOOP
EXECUTE 'GRANT USAGE ON SEQUENCE auth.' || quote_ident(r.sequence_name) || ' TO prisma';
END LOOP;
-- Grant privileges on all sequences in storage schema
FOR r IN SELECT sequence_name FROM information_schema.sequences WHERE sequence_schema = 'storage' LOOP
EXECUTE 'GRANT USAGE ON SEQUENCE storage.' || quote_ident(r.sequence_name) || ' TO prisma';
END LOOP;
-- Grant usage on all types in public schema
EXECUTE 'GRANT USAGE ON TYPE "public"."crime_rates" TO prisma';
EXECUTE 'GRANT USAGE ON TYPE "public"."crime_status" TO prisma';
EXECUTE 'GRANT USAGE ON TYPE "public"."session_status" TO prisma';
EXECUTE 'GRANT USAGE ON TYPE "public"."status_contact_messages" TO prisma';
EXECUTE 'GRANT USAGE ON TYPE "public"."unit_type" TO prisma';
END
$$;
-- Grant execute privileges on functions (separate DO block to avoid EXCEPTION issues)
DO $$
DECLARE
r RECORD;
BEGIN
-- Grant execute privileges on all functions in public schema
FOR r IN SELECT routines.routine_name
FROM information_schema.routines
WHERE routines.specific_schema = 'public'
AND routines.routine_type = 'FUNCTION' LOOP
BEGIN
EXECUTE 'GRANT EXECUTE ON FUNCTION public.' || quote_ident(r.routine_name) || '() TO prisma';
EXCEPTION WHEN OTHERS THEN
RAISE NOTICE 'Error granting execute on function public.%: %', r.routine_name, SQLERRM;
END;
END LOOP;
END
$$;
-- Handle gis functions in a separate block - with enhanced function existence checking
DO $$
DECLARE
r RECORD;
function_exists BOOLEAN;
BEGIN
-- Grant execute privileges on all functions in gis schema
FOR r IN SELECT routines.routine_name, routines.routine_schema,
array_to_string(array_agg(parameters.parameter_mode || ' ' ||
parameters.data_type), ', ') AS params
FROM information_schema.routines
LEFT JOIN information_schema.parameters ON
routines.specific_schema = parameters.specific_schema AND
routines.specific_name = parameters.specific_name
WHERE routines.specific_schema = 'gis'
AND routines.routine_type = 'FUNCTION'
GROUP BY routines.routine_name, routines.routine_schema
LOOP
BEGIN
-- Check if function exists with proper arguments
EXECUTE format('SELECT EXISTS(SELECT 1 FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname = %L AND p.proname = %L)',
r.routine_schema, r.routine_name)
INTO function_exists;
IF function_exists THEN
-- Use format to avoid '()' issue
EXECUTE format('GRANT EXECUTE ON FUNCTION %I.%I TO prisma', r.routine_schema, r.routine_name);
END IF;
EXCEPTION WHEN OTHERS THEN
RAISE NOTICE 'Error granting execute on function %.%: %', r.routine_schema, r.routine_name, SQLERRM;
END;
END LOOP;
END
$$;
-- Set default privileges for future objects
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO prisma;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO prisma;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON FUNCTIONS TO prisma;
ALTER DEFAULT PRIVILEGES IN SCHEMA gis GRANT ALL ON TABLES TO prisma;
ALTER DEFAULT PRIVILEGES IN SCHEMA gis GRANT ALL ON SEQUENCES TO prisma;
ALTER DEFAULT PRIVILEGES IN SCHEMA gis GRANT ALL ON FUNCTIONS TO prisma;
ALTER DEFAULT PRIVILEGES IN SCHEMA auth GRANT SELECT, DELETE ON TABLES TO prisma;
ALTER DEFAULT PRIVILEGES IN SCHEMA storage GRANT SELECT, DELETE ON TABLES TO prisma;
-- Ensure the prisma role has the necessary permissions for the auth schema triggers
DO $$
BEGIN
EXECUTE 'GRANT EXECUTE ON FUNCTION public.handle_new_user() TO prisma';
EXCEPTION WHEN OTHERS THEN
RAISE NOTICE 'Error granting execute on function public.handle_new_user(): %', SQLERRM;
END $$;
DO $$
BEGIN
EXECUTE 'GRANT EXECUTE ON FUNCTION public.handle_user_delete() TO prisma';
EXCEPTION WHEN OTHERS THEN
RAISE NOTICE 'Error granting execute on function public.handle_user_delete(): %', SQLERRM;
END $$;
DO $$
BEGIN
EXECUTE 'GRANT EXECUTE ON FUNCTION public.handle_user_update() TO prisma';
EXCEPTION WHEN OTHERS THEN
RAISE NOTICE 'Error granting execute on function public.handle_user_update(): %', SQLERRM;
END $$;
-- Grant postgres user the ability to manage prisma role
GRANT prisma TO postgres;

View File

@ -0,0 +1,11 @@
grant all privileges on all tables in schema public to postgres, anon, authenticated, service_role, prisma;
grant all privileges on all functions in schema public to postgres, anon, authenticated, service_role, prisma;
grant all privileges on all sequences in schema public to postgres, anon, authenticated, service_role, prisma;
alter default privileges in schema public grant all on tables to postgres, anon, authenticated, service_role, prisma;
alter default privileges in schema public grant all on functions to postgres, anon, authenticated, service_role, prisma;
alter default privileges in schema public grant all on sequences to postgres, anon, authenticated, service_role, prisma;
grant usage on schema "public" to anon;
grant usage on schema "public" to authenticated;
grant usage on schema "public" to prisma;

View File

@ -0,0 +1,11 @@
grant all privileges on all tables in schema public to postgres, anon, authenticated, service_role, prisma;
grant all privileges on all functions in schema public to postgres, anon, authenticated, service_role, prisma;
grant all privileges on all sequences in schema public to postgres, anon, authenticated, service_role, prisma;
alter default privileges in schema public grant all on tables to postgres, anon, authenticated, service_role, prisma;
alter default privileges in schema public grant all on functions to postgres, anon, authenticated, service_role, prisma;
alter default privileges in schema public grant all on sequences to postgres, anon, authenticated, service_role, prisma;
grant usage on schema "public" to anon;
grant usage on schema "public" to authenticated;
grant usage on schema "public" to prisma;

View File

@ -36,7 +36,7 @@ export class CrimeCategoriesSeeder {
const data = XLSX.utils.sheet_to_json(sheet) as ICrimeCategory[];
for (const category of crimeCategoriesData) {
const newId = generateId({
const newId = await generateId({
prefix: 'CC',
segments: {
sequentialDigits: 4,
@ -44,6 +44,8 @@ export class CrimeCategoriesSeeder {
randomSequence: false,
uniquenessStrategy: 'counter',
separator: '-',
tableName: 'crime_categories',
storage: 'database',
});
await this.prisma.crime_categories.create({

View File

@ -409,7 +409,7 @@ export class CrimeIncidentsSeeder {
}
// Generate a unique ID for the incident
const incidentId = generateId({
const incidentId = await generateId({
prefix: 'CI',
segments: {
codes: [district.cities.id],
@ -420,6 +420,8 @@ export class CrimeIncidentsSeeder {
separator: '-',
randomSequence: false,
uniquenessStrategy: 'counter',
storage: 'database',
tableName: 'crime_incidents',
});
// Determine status based on crime_cleared

View File

@ -177,7 +177,7 @@ export class CrimesSeeder {
const year = parseInt(record.year);
// Create a unique ID for monthly crime data
const crimeId = generateId({
const crimeId = await generateId({
prefix: 'CR',
segments: {
codes: [city.id],
@ -188,6 +188,8 @@ export class CrimesSeeder {
separator: '-',
randomSequence: false,
uniquenessStrategy: 'counter',
storage: 'database',
tableName: 'crimes',
});
await this.prisma.crimes.create({
@ -257,7 +259,7 @@ export class CrimesSeeder {
}
// Create a unique ID for yearly crime data
const crimeId = generateId({
const crimeId = await generateId({
prefix: 'CR',
segments: {
codes: [city.id],
@ -268,6 +270,8 @@ export class CrimesSeeder {
separator: '-',
randomSequence: false,
uniquenessStrategy: 'counter',
storage: 'database',
tableName: 'crimes',
});
await this.prisma.crimes.create({
@ -333,7 +337,7 @@ export class CrimesSeeder {
}
// Create a unique ID for all-year summary data
const crimeId = generateId({
const crimeId = await generateId({
prefix: 'CR',
segments: {
codes: [city.id],
@ -343,6 +347,8 @@ export class CrimesSeeder {
separator: '-',
randomSequence: false,
uniquenessStrategy: 'counter',
storage: 'database',
tableName: 'crimes',
});
await this.prisma.crimes.create({

View File

@ -89,7 +89,7 @@ export class UnitSeeder {
const address = location.address;
const phone = location.telepon?.replace(/-/g, '');
const code_unit = generateId({
const newId = await generateId({
prefix: 'UT',
format: '{prefix}-{sequence}',
separator: '-',
@ -98,11 +98,13 @@ export class UnitSeeder {
segments: {
sequentialDigits: 4,
},
storage: 'database',
tableName: 'units',
});
let locationData: CreateLocationDto = {
district_id: city.districts[0].id, // This will be replaced with Patrang's ID
code_unit: code_unit,
code_unit: newId,
name: `Polres ${city.name}`,
description: `Unit ${city.name} is categorized as POLRES and operates in the ${city.name} area.`,
type: 'polres',
@ -152,7 +154,7 @@ export class UnitSeeder {
const address = location.address;
const phone = location.telepon?.replace(/-/g, '');
const code_unit = generateId({
const newId = await generateId({
prefix: 'UT',
format: '{prefix}-{sequence}',
separator: '-',
@ -161,11 +163,13 @@ export class UnitSeeder {
segments: {
sequentialDigits: 4,
},
storage: 'database',
tableName: 'units',
});
const locationData: CreateLocationDto = {
district_id: district.id,
code_unit,
code_unit: newId,
name: `Polsek ${district.name}`,
description: `Unit ${district.name} is categorized as POLSEK and operates in the ${district.name} area.`,
type: 'polsek',
@ -187,7 +191,7 @@ export class UnitSeeder {
}
console.log(
`Inserted unit for district: ${district.name}, code_unit: ${code_unit} at ${lng}, ${lat}`
`Inserted unit for district: ${district.name}, newId: ${newId} at ${lng}, ${lat}`
);
}
}

View File

@ -68,60 +68,51 @@ CREATE TRIGGER on_auth_user_deleted BEFORE DELETE ON auth.users FOR EACH ROW EXE
CREATE TRIGGER on_auth_user_updated AFTER UPDATE ON auth.users FOR EACH ROW EXECUTE FUNCTION handle_user_update();
drop trigger if exists "objects_delete_delete_prefix" on "storage"."objects";
drop trigger if exists "objects_insert_create_prefix" on "storage"."objects";
drop trigger if exists "objects_update_create_prefix" on "storage"."objects";
drop trigger if exists "prefixes_create_hierarchy" on "storage"."prefixes";
drop trigger if exists "prefixes_delete_hierarchy" on "storage"."prefixes";
revoke delete on table "storage"."prefixes" from "anon";
revoke insert on table "storage"."prefixes" from "anon";
revoke references on table "storage"."prefixes" from "anon";
revoke select on table "storage"."prefixes" from "anon";
revoke trigger on table "storage"."prefixes" from "anon";
revoke truncate on table "storage"."prefixes" from "anon";
revoke update on table "storage"."prefixes" from "anon";
revoke delete on table "storage"."prefixes" from "authenticated";
revoke insert on table "storage"."prefixes" from "authenticated";
revoke references on table "storage"."prefixes" from "authenticated";
revoke select on table "storage"."prefixes" from "authenticated";
revoke trigger on table "storage"."prefixes" from "authenticated";
revoke truncate on table "storage"."prefixes" from "authenticated";
revoke update on table "storage"."prefixes" from "authenticated";
revoke delete on table "storage"."prefixes" from "service_role";
revoke insert on table "storage"."prefixes" from "service_role";
revoke references on table "storage"."prefixes" from "service_role";
revoke select on table "storage"."prefixes" from "service_role";
revoke trigger on table "storage"."prefixes" from "service_role";
revoke truncate on table "storage"."prefixes" from "service_role";
revoke update on table "storage"."prefixes" from "service_role";
alter table "storage"."prefixes" drop constraint "prefixes_bucketId_fkey";
DO $$
BEGIN
IF EXISTS (SELECT FROM pg_catalog.pg_tables
WHERE schemaname = 'storage'
AND tablename = 'prefixes') THEN
EXECUTE 'drop trigger if exists "prefixes_create_hierarchy" on "storage"."prefixes"';
EXECUTE 'drop trigger if exists "prefixes_delete_hierarchy" on "storage"."prefixes"';
EXECUTE 'revoke delete on table "storage"."prefixes" from "anon"';
EXECUTE 'revoke insert on table "storage"."prefixes" from "anon"';
EXECUTE 'revoke references on table "storage"."prefixes" from "anon"';
EXECUTE 'revoke select on table "storage"."prefixes" from "anon"';
EXECUTE 'revoke trigger on table "storage"."prefixes" from "anon"';
EXECUTE 'revoke truncate on table "storage"."prefixes" from "anon"';
EXECUTE 'revoke update on table "storage"."prefixes" from "anon"';
EXECUTE 'revoke delete on table "storage"."prefixes" from "authenticated"';
EXECUTE 'revoke insert on table "storage"."prefixes" from "authenticated"';
EXECUTE 'revoke references on table "storage"."prefixes" from "authenticated"';
EXECUTE 'revoke select on table "storage"."prefixes" from "authenticated"';
EXECUTE 'revoke trigger on table "storage"."prefixes" from "authenticated"';
EXECUTE 'revoke truncate on table "storage"."prefixes" from "authenticated"';
EXECUTE 'revoke update on table "storage"."prefixes" from "authenticated"';
EXECUTE 'revoke delete on table "storage"."prefixes" from "service_role"';
EXECUTE 'revoke insert on table "storage"."prefixes" from "service_role"';
EXECUTE 'revoke references on table "storage"."prefixes" from "service_role"';
EXECUTE 'revoke select on table "storage"."prefixes" from "service_role"';
EXECUTE 'revoke trigger on table "storage"."prefixes" from "service_role"';
EXECUTE 'revoke truncate on table "storage"."prefixes" from "service_role"';
EXECUTE 'revoke update on table "storage"."prefixes" from "service_role"';
EXECUTE 'alter table "storage"."prefixes" drop constraint if exists "prefixes_bucketId_fkey"';
EXECUTE 'alter table "storage"."prefixes" drop constraint if exists "prefixes_pkey"';
EXECUTE 'drop table "storage"."prefixes"';
END IF;
END $$;
drop function if exists "storage"."add_prefixes"(_bucket_id text, _name text);
@ -147,21 +138,41 @@ drop function if exists "storage"."search_v1_optimised"(prefix text, bucketname
drop function if exists "storage"."search_v2"(prefix text, bucket_name text, limits integer, levels integer, start_after text);
alter table "storage"."prefixes" drop constraint "prefixes_pkey";
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_indexes WHERE schemaname = 'storage' AND indexname = 'idx_name_bucket_level_unique') THEN
EXECUTE 'drop index "storage"."idx_name_bucket_level_unique"';
END IF;
IF EXISTS (SELECT 1 FROM pg_indexes WHERE schemaname = 'storage' AND indexname = 'idx_objects_lower_name') THEN
EXECUTE 'drop index "storage"."idx_objects_lower_name"';
END IF;
IF EXISTS (SELECT 1 FROM pg_indexes WHERE schemaname = 'storage' AND indexname = 'idx_prefixes_lower_name') THEN
EXECUTE 'drop index "storage"."idx_prefixes_lower_name"';
END IF;
IF EXISTS (SELECT 1 FROM pg_indexes WHERE schemaname = 'storage' AND indexname = 'objects_bucket_id_level_idx') THEN
EXECUTE 'drop index "storage"."objects_bucket_id_level_idx"';
END IF;
IF EXISTS (SELECT 1 FROM pg_indexes WHERE schemaname = 'storage' AND indexname = 'prefixes_pkey') THEN
EXECUTE 'drop index "storage"."prefixes_pkey"';
END IF;
END $$;
drop index if exists "storage"."idx_name_bucket_level_unique";
drop index if exists "storage"."idx_objects_lower_name";
drop index if exists "storage"."idx_prefixes_lower_name";
drop index if exists "storage"."objects_bucket_id_level_idx";
drop index if exists "storage"."prefixes_pkey";
drop table "storage"."prefixes";
alter table "storage"."objects" drop column "level";
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'storage'
AND table_name = 'objects'
AND column_name = 'level'
) THEN
EXECUTE 'alter table "storage"."objects" drop column "level"';
END IF;
END $$;
set check_function_bodies = off;
@ -316,12 +327,12 @@ using ((bucket_id = 'avatars'::text));
drop type "gis"."geometry_dump";
-- drop type "gis"."geometry_dump";
drop type "gis"."valid_detail";
-- drop type "gis"."valid_detail";
create type "gis"."geometry_dump" as ("path" integer[], "geom" geometry);
-- create type "gis"."geometry_dump" as ("path" integer[], "geom" geometry);
create type "gis"."valid_detail" as ("valid" boolean, "reason" character varying, "location" geometry);
-- create type "gis"."valid_detail" as ("valid" boolean, "reason" character varying, "location" geometry);

View File

@ -0,0 +1,204 @@
grant delete on table "storage"."s3_multipart_uploads" to "postgres";
grant insert on table "storage"."s3_multipart_uploads" to "postgres";
grant references on table "storage"."s3_multipart_uploads" to "postgres";
grant select on table "storage"."s3_multipart_uploads" to "postgres";
grant trigger on table "storage"."s3_multipart_uploads" to "postgres";
grant truncate on table "storage"."s3_multipart_uploads" to "postgres";
grant update on table "storage"."s3_multipart_uploads" to "postgres";
grant delete on table "storage"."s3_multipart_uploads_parts" to "postgres";
grant insert on table "storage"."s3_multipart_uploads_parts" to "postgres";
grant references on table "storage"."s3_multipart_uploads_parts" to "postgres";
grant select on table "storage"."s3_multipart_uploads_parts" to "postgres";
grant trigger on table "storage"."s3_multipart_uploads_parts" to "postgres";
grant truncate on table "storage"."s3_multipart_uploads_parts" to "postgres";
grant update on table "storage"."s3_multipart_uploads_parts" to "postgres";
-- drop type "gis"."geometry_dump";
-- drop type "gis"."valid_detail";
set check_function_bodies = off;
CREATE OR REPLACE FUNCTION gis.calculate_unit_incident_distances(p_unit_id character varying, p_district_id character varying DEFAULT NULL::character varying)
RETURNS TABLE(unit_code character varying, unit_name character varying, unit_lat double precision, unit_lng double precision, incident_id character varying, incident_description text, incident_lat double precision, incident_lng double precision, category_name character varying, district_name character varying, distance_meters double precision)
LANGUAGE plpgsql
SECURITY DEFINER
AS $function$
BEGIN
RETURN QUERY
WITH unit_locations AS (
SELECT
u.code_unit,
u.name,
u.latitude,
u.longitude,
u.district_id,
ST_SetSRID(ST_MakePoint(u.longitude, u.latitude), 4326)::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.description,
ci.crime_id,
ci.crime_category_id,
l.latitude,
l.longitude,
ST_SetSRID(ST_MakePoint(l.longitude, l.latitude), 4326)::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,
ul.name as unit_name,
ul.latitude as unit_lat,
ul.longitude as unit_lng,
il.id as incident_id,
il.description as incident_description,
il.latitude as incident_lat,
il.longitude as incident_lng,
cc.name as category_name,
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
JOIN
crime_categories cc ON il.crime_category_id = cc.id
ORDER BY
ul.code_unit,
ul.location <-> il.location; -- Use KNN operator for efficient ordering
END;
$function$
;
CREATE OR REPLACE FUNCTION gis.find_nearest_unit_to_incident(p_incident_id integer)
RETURNS TABLE(unit_code text, unit_name text, distance_meters double precision)
LANGUAGE plpgsql
SECURITY DEFINER
AS $function$
BEGIN
RETURN QUERY
WITH incident_location AS (
SELECT
ci.id,
ST_SetSRID(ST_MakePoint(
(ci.locations->>'longitude')::float,
(ci.locations->>'latitude')::float
), 4326)::geography AS location
FROM
crime_incidents ci
WHERE
ci.id = p_incident_id
AND (ci.locations->>'latitude') IS NOT NULL
AND (ci.locations->>'longitude') IS NOT NULL
),
unit_locations AS (
SELECT
u.code_unit,
u.name,
ST_SetSRID(ST_MakePoint(u.longitude, u.latitude), 4326)::geography AS location
FROM
units u
WHERE
u.latitude IS NOT NULL
AND u.longitude IS NOT NULL
)
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 integer, p_max_distance_meters double precision DEFAULT 5000)
RETURNS TABLE(unit_code text, unit_name text, distance_meters double precision)
LANGUAGE plpgsql
SECURITY DEFINER
AS $function$
BEGIN
RETURN QUERY
WITH incident_location AS (
SELECT
ci.id,
ST_SetSRID(ST_MakePoint(
(ci.locations->>'longitude')::float,
(ci.locations->>'latitude')::float
), 4326)::geography AS location
FROM
crime_incidents ci
WHERE
ci.id = p_incident_id
AND (ci.locations->>'latitude') IS NOT NULL
AND (ci.locations->>'longitude') IS NOT NULL
),
unit_locations AS (
SELECT
u.code_unit,
u.name,
ST_SetSRID(ST_MakePoint(u.longitude, u.latitude), 4326)::geography AS location
FROM
units u
WHERE
u.latitude IS NOT NULL
AND u.longitude IS NOT NULL
)
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);

View File

@ -0,0 +1,94 @@
-- drop type "gis"."geometry_dump";
-- drop type "gis"."valid_detail";
set check_function_bodies = off;
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);

View File

@ -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);