feat: Implement migration and seeding for officers and patrol units

- Added migration to change primary key type for patrol_units and update foreign key constraints in officers table.
- Created seeder for officers, generating random data and ensuring each officer is linked to a patrol unit.
- Developed seeder for patrol units, including logic for generating patrol types, statuses, and locations based on police units.
- Enhanced Supabase triggers for user creation, updates, and deletions to handle officer-specific logic and maintain data integrity.
- Introduced GIS functions and triggers to calculate distances between locations and units, optimizing spatial queries.
This commit is contained in:
vergiLgood1 2025-05-16 00:50:30 +07:00
parent 223195e3fb
commit 4b0bae1bcd
28 changed files with 3100 additions and 148 deletions

View File

@ -6,9 +6,8 @@ import {
AuthenticationError, AuthenticationError,
UnauthenticatedError, UnauthenticatedError,
} from '@/src/entities/errors/auth'; } from '@/src/entities/errors/auth';
import { InputParseError, NotFoundError } from '@/src/entities/errors/common'; import { NotFoundError } from '@/src/entities/errors/common';
import { districtsGeoJson } from '@/prisma/data/geojson/jember/districts';
import { calculateCentroid } from '@/app/_lib/transformGeoJSON';
/** /**
* Initialize district data in the database from GeoJSON * Initialize district data in the database from GeoJSON

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ICrimes } from "@/app/_utils/types/crimes"; import type { ICrimes } from "@/app/_utils/types/crimes";
import { import {
BASE_BEARING, BASE_BEARING,
@ -14,6 +14,7 @@ import IncidentPopup from "../pop-up/incident-popup";
import type mapboxgl from "mapbox-gl"; import type mapboxgl from "mapbox-gl";
import type { MapGeoJSONFeature, MapMouseEvent } from "react-map-gl/mapbox"; import type { MapGeoJSONFeature, MapMouseEvent } from "react-map-gl/mapbox";
import { manageLayerVisibility } from "@/app/_utils/map/layer-visibility"; import { manageLayerVisibility } from "@/app/_utils/map/layer-visibility";
import { getCategoryColor } from "@/app/_utils/colors";
interface IAllIncidentsLayerProps { interface IAllIncidentsLayerProps {
visible?: boolean; visible?: boolean;
@ -155,6 +156,22 @@ export default function AllIncidentsLayer(
useEffect(() => { useEffect(() => {
if (!map || !visible) return; if (!map || !visible) return;
// Get unique categories from crime data for more accurate color mapping
const uniqueCategories = new Set<string>();
crimes.forEach(crime => {
crime.crime_incidents.forEach(incident => {
if (incident.crime_categories?.name) {
uniqueCategories.add(incident.crime_categories.name);
}
});
});
// Pre-compute category colors for better performance
const categoryColorMap: Record<string, string> = {};
uniqueCategories.forEach(category => {
categoryColorMap[category] = getCategoryColor(category);
});
// Convert incidents to GeoJSON format // Convert incidents to GeoJSON format
const allIncidents = crimes.flatMap((crime) => { const allIncidents = crimes.flatMap((crime) => {
return crime.crime_incidents return crime.crime_incidents
@ -240,20 +257,12 @@ export default function AllIncidentsLayer(
"circle-color": [ "circle-color": [
"match", "match",
["get", "category"], ["get", "category"],
"Theft", // Use dynamic mapping from pre-computed category colors
"#FF5733", ...Object.entries(categoryColorMap).flatMap(
"Assault", ([category, color]) => [category, color]
"#C70039", ),
"Robbery",
"#900C3F",
"Burglary",
"#581845",
"Fraud",
"#FFC300",
"Homicide",
"#FF0000",
// Default color for other categories // Default color for other categories
"#2874A6", getCategoryColor("Unknown")
], ],
"circle-opacity": 0.4, "circle-opacity": 0.4,
"circle-blur": 0.6, "circle-blur": 0.6,
@ -278,20 +287,12 @@ export default function AllIncidentsLayer(
"circle-color": [ "circle-color": [
"match", "match",
["get", "category"], ["get", "category"],
"Theft", // Use dynamic mapping from pre-computed category colors
"#FF5733", ...Object.entries(categoryColorMap).flatMap(
"Assault", ([category, color]) => [category, color]
"#C70039", ),
"Robbery",
"#900C3F",
"Burglary",
"#581845",
"Fraud",
"#FFC300",
"Homicide",
"#FF0000",
// Default color for other categories // Default color for other categories
"#2874A6", getCategoryColor("Unknown")
], ],
"circle-stroke-width": 1, "circle-stroke-width": 1,
"circle-stroke-color": "#FFFFFF", "circle-stroke-color": "#FFFFFF",
@ -317,20 +318,12 @@ export default function AllIncidentsLayer(
"circle-color": [ "circle-color": [
"match", "match",
["get", "category"], ["get", "category"],
"Theft", // Use dynamic mapping from pre-computed category colors
"#FF5733", ...Object.entries(categoryColorMap).flatMap(
"Assault", ([category, color]) => [category, color]
"#C70039", ),
"Robbery",
"#900C3F",
"Burglary",
"#581845",
"Fraud",
"#FFC300",
"Homicide",
"#FF0000",
// Default color for other categories // Default color for other categories
"#2874A6", getCategoryColor("Unknown")
], ],
"circle-stroke-width": 1, "circle-stroke-width": 1,
"circle-stroke-color": "#FFFFFF", "circle-stroke-color": "#FFFFFF",

View File

@ -467,15 +467,13 @@ export default function Layers({
[], [],
); );
const showHeatmapLayer = activeControl === "heatmap" && const showHeatmapLayer = activeControl === "heatmap" && sourceType !== "cbu";
sourceType !== "cbu";
const showUnitsLayer = activeControl === "units"; const showUnitsLayer = activeControl === "units";
const showTimelineLayer = activeControl === "timeline"; const showTimelineLayer = activeControl === "timeline";
const showRecentIncidents = activeControl === "recents"; const showRecentIncidents = activeControl === "recents";
const showAllIncidents = activeControl === "incidents"; // Use the "incidents" tooltip for all incidents const showAllIncidents = activeControl === "incidents"; // Use the "incidents" tooltip for all incidents
const showDistrictFill = activeControl === "incidents" || const showDistrictFill = activeControl === "clusters";
activeControl === "clusters" ||
activeControl === "recents";
const showIncidentMarkers = activeControl !== "heatmap" && const showIncidentMarkers = activeControl !== "heatmap" &&
activeControl !== "timeline" && sourceType !== "cbu"; activeControl !== "timeline" && sourceType !== "cbu";
@ -504,7 +502,7 @@ export default function Layers({
"crime-points", "crime-points",
"crime-count-labels", "crime-count-labels",
]; ];
const unclusteredLayerIds = ["unclustered-point"];
const allIncidentsLayerIds = [ const allIncidentsLayerIds = [
"all-incidents-pulse", "all-incidents-pulse",
"all-incidents-circles", "all-incidents-circles",
@ -535,9 +533,7 @@ export default function Layers({
manageLayerVisibility(mapboxMap, allIncidentsLayerIds, false); manageLayerVisibility(mapboxMap, allIncidentsLayerIds, false);
} }
if (activeControl !== "incidents" && activeControl !== "recents") {
manageLayerVisibility(mapboxMap, unclusteredLayerIds, false);
}
}, [activeControl, mapboxMap]); }, [activeControl, mapboxMap]);
return ( return (

View File

@ -10,7 +10,8 @@ import db from '../../prisma/db';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import { CRIME_RATE_COLORS } from './const/map'; import { CRIME_RATE_COLORS } from './const/map';
import { districtsGeoJson } from '../../prisma/data/geojson/jember/districts'; import { districtsGeoJson } from '../../prisma/data/geojson/jember/districts-geojson';
// Used to track generated IDs // Used to track generated IDs
const usedIdRegistry = new Set<string>(); const usedIdRegistry = new Set<string>();

View File

@ -1,6 +1,8 @@
export class CRegex { export class CRegex {
static readonly PHONE_REGEX = /^(\+62|62|0)[8][1-9][0-9]{6,11}$/; static readonly PHONE_REGEX = /^(\+62|62|0)[8][1-9][0-9]{6,11}$/;
static readonly BAN_DURATION_REGEX = /^(\d+(ns|us|µs|ms|s|m|h)|none)$/; static readonly BAN_DURATION_REGEX = /^(\d+(ns|us|µs|ms|s|m|h)|none)$/;
static readonly CR_YEAR_SEQUENCE = /(\d{4})(?=-\d{4}$)/; static readonly FORMAT_ID_YEAR_SEQUENCE = /(\d{4})(?=-\d{4}$)/;
static readonly CR_SEQUENCE_END = /(\d{4})$/; static readonly FORMAT_ID_SEQUENCE_END = /(\d{4})$/;
static readonly FORMAT_ID_SEQUENCE = /(\d{4})(?=-\d{2}$)/;
static readonly PATROL_UNIT_ID_REGEX = /^PU-(\w{3,})(\d{2})$/;
} }

View File

@ -0,0 +1,63 @@
# Conditional Triggering System with Supabase Auth
This document describes how our custom authentication triggers work with Supabase Auth to handle different user types.
## Overview
The system conditionally routes new user registrations to either:
1. Standard users - stored in `users` and `profiles` tables
2. Officer users - stored in the `officers` table
## How It Works
### User Registration
When a user signs up via Supabase Auth:
1. The `handle_new_user` trigger function checks the user metadata for an `is_officer` flag
2. Standard users are created in the `users` and `profiles` tables
3. Officer users are created in the `officers` table with their specialized data
### User Updates
When a user is updated in Supabase Auth:
1. The `handle_user_update` function determines if the user is an officer
2. Updates are applied to the appropriate table based on user type
3. Officer-specific data is extracted from the `officer_data` metadata field
### User Deletion
When a user is deleted from Supabase Auth:
1. The `handle_user_delete` function determines if the user is an officer
2. Removes the user from the appropriate tables based on user type
### User Type Changes
When a user's type changes (e.g., from standard user to officer):
1. The `handle_user_type_change` function handles the transition
2. Data is moved from one set of tables to another as appropriate
## Metadata Structure
For officer registrations, the following metadata structure is expected:
```json
{
"is_officer": true,
"officer_data": {
"unit_id": "required_unit_id",
"nrp": "officer_nrp",
"rank": "officer_rank",
"position": "officer_position",
"phone": "optional_phone"
}
}
```
## Implementation
These triggers are implemented in the database and automatically run when Supabase Auth events occur.

View File

@ -0,0 +1,423 @@
-- Updated function to handle conditional user creation based on metadata
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
AS $$
DECLARE
role_id UUID;
officer_role_id UUID;
is_officer BOOLEAN;
officer_data JSONB;
unit_id VARCHAR;
BEGIN
-- Check if the user is registering as an officer
is_officer := FALSE;
-- Check user_metadata for officer flag
IF NEW.raw_user_meta_data ? 'is_officer' THEN
is_officer := (NEW.raw_user_meta_data->>'is_officer')::boolean;
END IF;
IF is_officer THEN
-- Get officer role ID
SELECT id INTO officer_role_id FROM public.roles WHERE name = 'officer' LIMIT 1;
IF officer_role_id IS NULL THEN
RAISE EXCEPTION 'Officer role not found';
END IF;
-- Extract officer data from metadata
officer_data := NEW.raw_user_meta_data->'officer_data';
-- Get unit ID from metadata
unit_id := officer_data->>'unit_id';
IF unit_id IS NULL THEN
RAISE EXCEPTION 'Unit ID is required for officer registration';
END IF;
-- Insert into officers table
INSERT INTO public.officers (
id,
unit_id,
role_id,
nrp,
name,
rank,
position,
phone,
email,
created_at,
updated_at
) VALUES (
NEW.id,
unit_id,
officer_role_id,
officer_data->>'nrp',
COALESCE(officer_data->>'name', NEW.email),
officer_data->>'rank',
officer_data->>'position',
COALESCE(NEW.phone, officer_data->>'phone'),
NEW.email,
NEW.created_at,
NEW.updated_at
);
-- Return early since we've handled the officer case
RETURN NEW;
ELSE
-- Standard user registration - Get viewer role ID
SELECT id INTO role_id FROM public.roles WHERE name = 'viewer' LIMIT 1;
IF role_id IS NULL THEN
RAISE EXCEPTION 'Viewer role not found';
END IF;
-- Insert into users table
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 profiles table
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 IF;
END;
$$;
-- Create or replace trigger for user creation
DROP TRIGGER IF EXISTS "on_auth_user_created" ON "auth"."users";
CREATE TRIGGER "on_auth_user_created"
AFTER INSERT ON "auth"."users"
FOR EACH ROW
EXECUTE FUNCTION public.handle_new_user();
-- Updated function to handle conditional user update based on metadata
CREATE OR REPLACE FUNCTION public.handle_user_update()
RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
AS $$
DECLARE
is_officer BOOLEAN;
officer_data JSONB;
BEGIN
-- Check if the user is an officer
is_officer := EXISTS (SELECT 1 FROM public.officers WHERE id = NEW.id);
-- Also check if user_metadata indicates officer status (for cases where metadata was updated)
IF NOT is_officer AND NEW.raw_user_meta_data ? 'is_officer' THEN
is_officer := (NEW.raw_user_meta_data->>'is_officer')::boolean;
END IF;
IF is_officer THEN
-- Extract officer data from metadata if it exists
IF NEW.raw_user_meta_data ? 'officer_data' THEN
officer_data := NEW.raw_user_meta_data->'officer_data';
-- Update officer record
UPDATE public.officers
SET
nrp = COALESCE(officer_data->>'nrp', nrp),
name = COALESCE(officer_data->>'name', name),
rank = COALESCE(officer_data->>'rank', rank),
position = COALESCE(officer_data->>'position', position),
phone = COALESCE(NEW.phone, officer_data->>'phone', phone),
email = COALESCE(NEW.email, email),
updated_at = NOW()
WHERE id = NEW.id;
ELSE
-- Basic update with available auth data
UPDATE public.officers
SET
phone = COALESCE(NEW.phone, phone),
email = COALESCE(NEW.email, email),
updated_at = NOW()
WHERE id = NEW.id;
END IF;
ELSE
-- Standard user update
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;
-- Create profile if it doesn't exist
INSERT INTO public.profiles (id, user_id, username)
SELECT gen_random_uuid(), NEW.id, public.generate_username(NEW.email)
WHERE NOT EXISTS (
SELECT 1 FROM public.profiles WHERE user_id = NEW.id
)
ON CONFLICT (user_id) DO NOTHING;
END IF;
RETURN NEW;
END;
$$;
-- Create or replace trigger for user updates
DROP TRIGGER IF EXISTS "on_auth_user_updated" ON "auth"."users";
CREATE TRIGGER "on_auth_user_updated"
AFTER UPDATE ON "auth"."users"
FOR EACH ROW
WHEN (OLD.* IS DISTINCT FROM NEW.*)
EXECUTE FUNCTION public.handle_user_update();
-- Updated function to handle conditional user deletion based on role
CREATE OR REPLACE FUNCTION public.handle_user_delete()
RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
AS $$
DECLARE
is_officer BOOLEAN;
BEGIN
-- Check if the user is an officer
is_officer := EXISTS (SELECT 1 FROM public.officers WHERE id = OLD.id);
IF is_officer THEN
-- Delete officer record
DELETE FROM public.officers WHERE id = OLD.id;
ELSE
-- Delete standard user data
DELETE FROM public.profiles WHERE user_id = OLD.id;
DELETE FROM public.users WHERE id = OLD.id;
END IF;
RETURN OLD;
END;
$$;
-- Create or replace trigger for user deletion
DROP TRIGGER IF EXISTS "on_auth_user_deleted" ON "auth"."users";
CREATE TRIGGER "on_auth_user_deleted"
AFTER DELETE ON "auth"."users"
FOR EACH ROW
EXECUTE FUNCTION public.handle_user_delete();
-- Function to handle when a user is converted to/from an officer
CREATE OR REPLACE FUNCTION public.handle_user_type_change()
RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
AS $$
DECLARE
is_officer_before BOOLEAN;
is_officer_after BOOLEAN;
officer_role_id UUID;
viewer_role_id UUID;
officer_data JSONB;
unit_id VARCHAR;
BEGIN
-- Determine officer status before and after update
is_officer_before := EXISTS (SELECT 1 FROM public.officers WHERE id = NEW.id);
-- Check if user_metadata indicates officer status after update
is_officer_after := FALSE;
IF NEW.raw_user_meta_data ? 'is_officer' THEN
is_officer_after := (NEW.raw_user_meta_data->>'is_officer')::boolean;
END IF;
-- If status changed from regular user to officer
IF NOT is_officer_before AND is_officer_after THEN
-- Get officer role ID
SELECT id INTO officer_role_id FROM public.roles WHERE name = 'officer' LIMIT 1;
IF officer_role_id IS NULL THEN
RAISE EXCEPTION 'Officer role not found';
END IF;
-- Extract officer data from metadata
officer_data := NEW.raw_user_meta_data->'officer_data';
-- Get unit ID from metadata
unit_id := officer_data->>'unit_id';
IF unit_id IS NULL THEN
RAISE EXCEPTION 'Unit ID is required for officer registration';
END IF;
-- Insert into officers table
INSERT INTO public.officers (
id,
unit_id,
role_id,
nrp,
name,
rank,
position,
phone,
email,
created_at,
updated_at
) VALUES (
NEW.id,
unit_id,
officer_role_id,
officer_data->>'nrp',
COALESCE(officer_data->>'name', NEW.email),
officer_data->>'rank',
officer_data->>'position',
COALESCE(NEW.phone, officer_data->>'phone'),
NEW.email,
NEW.created_at,
NEW.updated_at
);
-- Delete regular user data
DELETE FROM public.profiles WHERE user_id = NEW.id;
DELETE FROM public.users WHERE id = NEW.id;
-- If status changed from officer to regular user
ELSIF is_officer_before AND NOT is_officer_after THEN
-- Get viewer role ID
SELECT id INTO viewer_role_id FROM public.roles WHERE name = 'viewer' LIMIT 1;
IF viewer_role_id IS NULL THEN
RAISE EXCEPTION 'Viewer role not found';
END IF;
-- Insert into users table
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,
viewer_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 profiles table
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
);
-- Delete officer record
DELETE FROM public.officers WHERE id = NEW.id;
END IF;
RETURN NEW;
END;
$$;
-- Create or replace trigger for user type changes
DROP TRIGGER IF EXISTS "on_auth_user_type_change" ON "auth"."users";
CREATE TRIGGER "on_auth_user_type_change"
AFTER UPDATE ON "auth"."users"
FOR EACH ROW
WHEN (
(OLD.raw_user_meta_data->>'is_officer')::boolean IS DISTINCT FROM
(NEW.raw_user_meta_data->>'is_officer')::boolean
)
EXECUTE FUNCTION public.handle_user_type_change();
-- Add an informational message about trigger creation
DO $$
BEGIN
RAISE NOTICE 'All authentication triggers have been created successfully';
END $$;

View File

@ -0,0 +1,560 @@
export interface DistrictPoint {
lat: number;
lng: number;
}
export interface DistrictCoordinates {
kecamatan: string;
points: DistrictPoint[];
}
export const districtCoordinates: DistrictCoordinates[] = [
{
kecamatan: "Sumbersari",
points: [
{ lat: -8.1762, lng: 113.7211 },
{ lat: -8.1834, lng: 113.7267 },
{ lat: -8.1895, lng: 113.7159 },
{ lat: -8.1811, lng: 113.7119 },
{ lat: -8.1742, lng: 113.7168 },
{ lat: -8.1804, lng: 113.7305 },
{ lat: -8.1877, lng: 113.7234 },
{ lat: -8.1921, lng: 113.7195 },
{ lat: -8.1851, lng: 113.7178 },
{ lat: -8.1788, lng: 113.7254 },
{ lat: -8.1767, lng: 113.7143 },
{ lat: -8.1863, lng: 113.7291 },
{ lat: -8.1925, lng: 113.7109 }
]
},
{
kecamatan: "Patrang",
points: [
{ lat: -8.1357, lng: 113.7181 },
{ lat: -8.1415, lng: 113.7245 },
{ lat: -8.1492, lng: 113.7136 },
{ lat: -8.1379, lng: 113.7098 },
{ lat: -8.1445, lng: 113.7201 },
{ lat: -8.1532, lng: 113.7167 },
{ lat: -8.1398, lng: 113.7153 },
{ lat: -8.1463, lng: 113.7092 },
{ lat: -8.1511, lng: 113.7222 },
{ lat: -8.1345, lng: 113.7121 },
{ lat: -8.1428, lng: 113.7018 },
{ lat: -8.1485, lng: 113.7264 },
{ lat: -8.1553, lng: 113.7109 },
{ lat: -8.1389, lng: 113.7215 }
]
},
{
kecamatan: "Kaliwates",
points: [
{ lat: -8.1854, lng: 113.6824 },
{ lat: -8.1892, lng: 113.6901 },
{ lat: -8.1921, lng: 113.6843 },
{ lat: -8.1819, lng: 113.6875 },
{ lat: -8.1863, lng: 113.6765 },
{ lat: -8.1937, lng: 113.6798 },
{ lat: -8.1801, lng: 113.6812 },
{ lat: -8.1845, lng: 113.6956 },
{ lat: -8.1907, lng: 113.6925 },
{ lat: -8.1876, lng: 113.6834 },
{ lat: -8.1832, lng: 113.6889 },
{ lat: -8.1923, lng: 113.6865 },
{ lat: -8.1789, lng: 113.6923 }
]
},
{
kecamatan: "Ambulu",
points: [
{ lat: -8.3521, lng: 113.6098 },
{ lat: -8.3642, lng: 113.6175 },
{ lat: -8.3487, lng: 113.6145 },
{ lat: -8.3592, lng: 113.6045 },
{ lat: -8.3561, lng: 113.6234 },
{ lat: -8.3712, lng: 113.6123 },
{ lat: -8.3452, lng: 113.6067 },
{ lat: -8.3534, lng: 113.6187 },
{ lat: -8.3671, lng: 113.6089 },
{ lat: -8.3501, lng: 113.6234 },
{ lat: -8.3623, lng: 113.6012 },
{ lat: -8.3432, lng: 113.6178 },
{ lat: -8.3581, lng: 113.6143 },
{ lat: -8.3691, lng: 113.6209 }
]
},
{
kecamatan: "Arjasa",
points: [
{ lat: -8.1034, lng: 113.7398 },
{ lat: -8.1125, lng: 113.7467 },
{ lat: -8.0987, lng: 113.7512 },
{ lat: -8.1076, lng: 113.7345 },
{ lat: -8.1156, lng: 113.7423 },
{ lat: -8.0956, lng: 113.7401 },
{ lat: -8.1097, lng: 113.7532 },
{ lat: -8.1187, lng: 113.7378 },
{ lat: -8.1023, lng: 113.7456 },
{ lat: -8.1145, lng: 113.7501 },
{ lat: -8.0975, lng: 113.7467 },
{ lat: -8.1112, lng: 113.7389 },
{ lat: -8.1067, lng: 113.7442 }
]
},
{
kecamatan: "Bangsalsari",
points: [
{ lat: -8.0734, lng: 113.5398 },
{ lat: -8.0819, lng: 113.5467 },
{ lat: -8.0762, lng: 113.5512 },
{ lat: -8.0693, lng: 113.5345 },
{ lat: -8.0845, lng: 113.5423 },
{ lat: -8.0771, lng: 113.5401 },
{ lat: -8.0712, lng: 113.5532 },
{ lat: -8.0856, lng: 113.5378 },
{ lat: -8.0789, lng: 113.5456 },
{ lat: -8.0723, lng: 113.5501 },
{ lat: -8.0834, lng: 113.5467 },
{ lat: -8.0767, lng: 113.5389 },
{ lat: -8.0801, lng: 113.5442 },
{ lat: -8.0745, lng: 113.5489 }
]
},
{
kecamatan: "Balung",
points: [
{ lat: -8.2534, lng: 113.5398 },
{ lat: -8.2619, lng: 113.5467 },
{ lat: -8.2562, lng: 113.5512 },
{ lat: -8.2493, lng: 113.5345 },
{ lat: -8.2645, lng: 113.5423 },
{ lat: -8.2571, lng: 113.5401 },
{ lat: -8.2512, lng: 113.5532 },
{ lat: -8.2656, lng: 113.5378 },
{ lat: -8.2589, lng: 113.5456 },
{ lat: -8.2523, lng: 113.5501 },
{ lat: -8.2634, lng: 113.5467 },
{ lat: -8.2567, lng: 113.5389 },
{ lat: -8.2601, lng: 113.5442 }
]
},
{
kecamatan: "Gumukmas",
points: [
{ lat: -8.2891, lng: 113.4398 },
{ lat: -8.2943, lng: 113.4467 },
{ lat: -8.2912, lng: 113.4512 },
{ lat: -8.2863, lng: 113.4345 },
{ lat: -8.2932, lng: 113.4423 },
{ lat: -8.2873, lng: 113.4401 },
{ lat: -8.2923, lng: 113.4532 },
{ lat: -8.2881, lng: 113.4378 },
{ lat: -8.2956, lng: 113.4456 },
{ lat: -8.2903, lng: 113.4501 },
{ lat: -8.2847, lng: 113.4467 },
{ lat: -8.2913, lng: 113.4389 },
{ lat: -8.2879, lng: 113.4442 }
]
},
{
kecamatan: "Jelbuk",
points: [
{ lat: -8.0634, lng: 113.7598 },
{ lat: -8.0719, lng: 113.7667 },
{ lat: -8.0662, lng: 113.7712 },
{ lat: -8.0593, lng: 113.7545 },
{ lat: -8.0745, lng: 113.7623 },
{ lat: -8.0671, lng: 113.7601 },
{ lat: -8.0612, lng: 113.7732 },
{ lat: -8.0756, lng: 113.7578 },
{ lat: -8.0689, lng: 113.7656 },
{ lat: -8.0623, lng: 113.7701 },
{ lat: -8.0734, lng: 113.7667 },
{ lat: -8.0667, lng: 113.7589 },
{ lat: -8.0701, lng: 113.7642 }
]
},
{
kecamatan: "Jenggawah",
points: [
{ lat: -8.2415, lng: 113.6231 },
{ lat: -8.2465, lng: 113.6287 },
{ lat: -8.2398, lng: 113.6342 },
{ lat: -8.2437, lng: 113.6175 },
{ lat: -8.2487, lng: 113.6218 },
{ lat: -8.2412, lng: 113.6311 },
{ lat: -8.2453, lng: 113.6357 },
{ lat: -8.2501, lng: 113.6263 },
{ lat: -8.2375, lng: 113.6298 },
{ lat: -8.2431, lng: 113.6245 },
{ lat: -8.2478, lng: 113.6324 },
{ lat: -8.2421, lng: 113.6187 },
{ lat: -8.2458, lng: 113.6276 }
]
},
{
kecamatan: "Jombang",
points: [
{ lat: -8.1878, lng: 113.5245 },
{ lat: -8.1923, lng: 113.5312 },
{ lat: -8.1956, lng: 113.5267 },
{ lat: -8.1897, lng: 113.5187 },
{ lat: -8.1945, lng: 113.5235 },
{ lat: -8.1876, lng: 113.5301 },
{ lat: -8.1934, lng: 113.5178 },
{ lat: -8.1901, lng: 113.5256 },
{ lat: -8.1967, lng: 113.5223 },
{ lat: -8.1912, lng: 113.5289 },
{ lat: -8.1889, lng: 113.5212 },
{ lat: -8.1947, lng: 113.5176 },
{ lat: -8.1921, lng: 113.5345 }
]
},
{
kecamatan: "Kalisat",
points: [
{ lat: -8.1278, lng: 113.8234 },
{ lat: -8.1323, lng: 113.8289 },
{ lat: -8.1256, lng: 113.8321 },
{ lat: -8.1289, lng: 113.8175 },
{ lat: -8.1345, lng: 113.8203 },
{ lat: -8.1312, lng: 113.8256 },
{ lat: -8.1267, lng: 113.8232 },
{ lat: -8.1334, lng: 113.8312 },
{ lat: -8.1301, lng: 113.8345 },
{ lat: -8.1356, lng: 113.8267 },
{ lat: -8.1287, lng: 113.8298 },
{ lat: -8.1342, lng: 113.8156 },
{ lat: -8.1276, lng: 113.8187 },
{ lat: -8.1328, lng: 113.8231 }
]
},
{
kecamatan: "Kencong",
points: [
{ lat: -8.2815, lng: 113.3824 },
{ lat: -8.2879, lng: 113.3875 },
{ lat: -8.2845, lng: 113.3921 },
{ lat: -8.2799, lng: 113.3798 },
{ lat: -8.2867, lng: 113.3842 },
{ lat: -8.2834, lng: 113.3787 },
{ lat: -8.2789, lng: 113.3864 },
{ lat: -8.2856, lng: 113.3909 },
{ lat: -8.2823, lng: 113.3953 },
{ lat: -8.2878, lng: 113.3812 },
{ lat: -8.2798, lng: 113.3923 },
{ lat: -8.2845, lng: 113.3864 },
{ lat: -8.2812, lng: 113.3897 }
]
},
{
kecamatan: "Ledokombo",
points: [
{ lat: -8.1576, lng: 113.9089 },
{ lat: -8.1623, lng: 113.9145 },
{ lat: -8.1587, lng: 113.9201 },
{ lat: -8.1542, lng: 113.9157 },
{ lat: -8.1598, lng: 113.9023 },
{ lat: -8.1556, lng: 113.9067 },
{ lat: -8.1612, lng: 113.9112 },
{ lat: -8.1531, lng: 113.9178 },
{ lat: -8.1574, lng: 113.9231 },
{ lat: -8.1634, lng: 113.9056 },
{ lat: -8.1589, lng: 113.9124 },
{ lat: -8.1545, lng: 113.9212 },
{ lat: -8.1601, lng: 113.9175 },
{ lat: -8.1565, lng: 113.9034 }
]
},
{
kecamatan: "Mayang",
points: [
{ lat: -8.1973, lng: 113.8034 },
{ lat: -8.2012, lng: 113.8089 },
{ lat: -8.1942, lng: 113.8067 },
{ lat: -8.1987, lng: 113.8112 },
{ lat: -8.2032, lng: 113.8056 },
{ lat: -8.1965, lng: 113.8123 },
{ lat: -8.2021, lng: 113.8134 },
{ lat: -8.1934, lng: 113.8012 },
{ lat: -8.1998, lng: 113.8023 },
{ lat: -8.2043, lng: 113.8098 },
{ lat: -8.1956, lng: 113.8078 },
{ lat: -8.2001, lng: 113.8156 },
{ lat: -8.1923, lng: 113.8042 }
]
},
{
kecamatan: "Mumbulsari",
points: [
{ lat: -8.2576, lng: 113.7834 },
{ lat: -8.2632, lng: 113.7889 },
{ lat: -8.2598, lng: 113.7956 },
{ lat: -8.2561, lng: 113.7912 },
{ lat: -8.2587, lng: 113.7845 },
{ lat: -8.2621, lng: 113.7923 },
{ lat: -8.2567, lng: 113.7967 },
{ lat: -8.2612, lng: 113.7834 },
{ lat: -8.2581, lng: 113.7901 },
{ lat: -8.2642, lng: 113.7945 },
{ lat: -8.2601, lng: 113.7978 },
{ lat: -8.2545, lng: 113.7856 },
{ lat: -8.2623, lng: 113.7812 }
]
},
{
kecamatan: "Pakusari",
points: [
{ lat: -8.1234, lng: 113.7712 },
{ lat: -8.1287, lng: 113.7756 },
{ lat: -8.1253, lng: 113.7823 },
{ lat: -8.1212, lng: 113.7778 },
{ lat: -8.1267, lng: 113.7712 },
{ lat: -8.1234, lng: 113.7845 },
{ lat: -8.1289, lng: 113.7801 },
{ lat: -8.1321, lng: 113.7734 },
{ lat: -8.1201, lng: 113.7723 },
{ lat: -8.1245, lng: 113.7789 },
{ lat: -8.1298, lng: 113.7845 },
{ lat: -8.1223, lng: 113.7856 },
{ lat: -8.1273, lng: 113.7701 }
]
},
{
kecamatan: "Panti",
points: [
{ lat: -8.0873, lng: 113.6123 },
{ lat: -8.0912, lng: 113.6187 },
{ lat: -8.0874, lng: 113.6234 },
{ lat: -8.0834, lng: 113.6167 },
{ lat: -8.0893, lng: 113.6097 },
{ lat: -8.0945, lng: 113.6156 },
{ lat: -8.0823, lng: 113.6201 },
{ lat: -8.0867, lng: 113.6278 },
{ lat: -8.0923, lng: 113.6232 },
{ lat: -8.0878, lng: 113.6121 },
{ lat: -8.0845, lng: 113.6267 },
{ lat: -8.0912, lng: 113.6089 },
{ lat: -8.0956, lng: 113.6245 },
{ lat: -8.0889, lng: 113.6178 }
]
},
{
kecamatan: "Puger",
points: [
{ lat: -8.3712, lng: 113.4734 },
{ lat: -8.3756, lng: 113.4789 },
{ lat: -8.3699, lng: 113.4823 },
{ lat: -8.3734, lng: 113.4767 },
{ lat: -8.3678, lng: 113.4798 },
{ lat: -8.3723, lng: 113.4845 },
{ lat: -8.3789, lng: 113.4756 },
{ lat: -8.3645, lng: 113.4789 },
{ lat: -8.3701, lng: 113.4867 },
{ lat: -8.3756, lng: 113.4834 },
{ lat: -8.3712, lng: 113.4712 },
{ lat: -8.3678, lng: 113.4745 },
{ lat: -8.3767, lng: 113.4801 },
{ lat: -8.3723, lng: 113.4878 }
]
},
{
kecamatan: "Rambipuji",
points: [
{ lat: -8.2067, lng: 113.6123 },
{ lat: -8.2112, lng: 113.6178 },
{ lat: -8.2156, lng: 113.6134 },
{ lat: -8.2026, lng: 113.6089 },
{ lat: -8.2076, lng: 113.6212 },
{ lat: -8.2123, lng: 113.6245 },
{ lat: -8.2056, lng: 113.6267 },
{ lat: -8.2145, lng: 113.6089 },
{ lat: -8.2089, lng: 113.6167 },
{ lat: -8.2034, lng: 113.6198 },
{ lat: -8.2087, lng: 113.6245 },
{ lat: -8.2156, lng: 113.6187 },
{ lat: -8.2045, lng: 113.6123 }
]
},
{
kecamatan: "Semboro",
points: [
{ lat: -8.1923, lng: 113.4512 },
{ lat: -8.1976, lng: 113.4576 },
{ lat: -8.1945, lng: 113.4623 },
{ lat: -8.1897, lng: 113.4545 },
{ lat: -8.1967, lng: 113.4598 },
{ lat: -8.1912, lng: 113.4512 },
{ lat: -8.1887, lng: 113.4569 },
{ lat: -8.1956, lng: 113.4534 },
{ lat: -8.1923, lng: 113.4589 },
{ lat: -8.1876, lng: 113.4623 },
{ lat: -8.1934, lng: 113.4512 },
{ lat: -8.1978, lng: 113.4545 },
{ lat: -8.1901, lng: 113.4578 }
]
},
{
kecamatan: "Silo",
points: [
{ lat: -8.2356, lng: 114.0012 },
{ lat: -8.2401, lng: 114.0089 },
{ lat: -8.2367, lng: 114.0145 },
{ lat: -8.2312, lng: 114.0065 },
{ lat: -8.2354, lng: 114.0123 },
{ lat: -8.2423, lng: 114.0034 },
{ lat: -8.2378, lng: 114.0178 },
{ lat: -8.2334, lng: 114.0123 },
{ lat: -8.2389, lng: 114.0256 },
{ lat: -8.2345, lng: 114.0201 },
{ lat: -8.2398, lng: 114.0156 },
{ lat: -8.2423, lng: 114.0098 },
{ lat: -8.2356, lng: 114.0243 }
]
},
{
kecamatan: "Sukorambi",
points: [
{ lat: -8.1123, lng: 113.6523 },
{ lat: -8.1167, lng: 113.6576 },
{ lat: -8.1098, lng: 113.6543 },
{ lat: -8.1145, lng: 113.6601 },
{ lat: -8.1187, lng: 113.6534 },
{ lat: -8.1098, lng: 113.6589 },
{ lat: -8.1156, lng: 113.6623 },
{ lat: -8.1112, lng: 113.6501 },
{ lat: -8.1178, lng: 113.6578 },
{ lat: -8.1134, lng: 113.6623 },
{ lat: -8.1089, lng: 113.6567 },
{ lat: -8.1156, lng: 113.6509 },
{ lat: -8.1123, lng: 113.6554 }
]
},
{
kecamatan: "Sukowono",
points: [
{ lat: -8.0576, lng: 113.8923 },
{ lat: -8.0623, lng: 113.8965 },
{ lat: -8.0589, lng: 113.9034 },
{ lat: -8.0532, lng: 113.8967 },
{ lat: -8.0578, lng: 113.9012 },
{ lat: -8.0621, lng: 113.8897 },
{ lat: -8.0556, lng: 113.8945 },
{ lat: -8.0612, lng: 113.9089 },
{ lat: -8.0545, lng: 113.9021 },
{ lat: -8.0598, lng: 113.8956 },
{ lat: -8.0576, lng: 113.9045 },
{ lat: -8.0634, lng: 113.8989 },
{ lat: -8.0567, lng: 113.8923 }
]
},
{
kecamatan: "Sumberbaru",
points: [
{ lat: -8.1323, lng: 113.4023 },
{ lat: -8.1376, lng: 113.4089 },
{ lat: -8.1343, lng: 113.4156 },
{ lat: -8.1287, lng: 113.4045 },
{ lat: -8.1356, lng: 113.4112 },
{ lat: -8.1312, lng: 113.4178 },
{ lat: -8.1398, lng: 113.4045 },
{ lat: -8.1332, lng: 113.4189 },
{ lat: -8.1276, lng: 113.4123 },
{ lat: -8.1345, lng: 113.4067 },
{ lat: -8.1389, lng: 113.4112 },
{ lat: -8.1323, lng: 113.4234 },
{ lat: -8.1367, lng: 113.4176 }
]
},
{
kecamatan: "Sumberjambe",
points: [
{ lat: -8.0678, lng: 113.9456 },
{ lat: -8.0734, lng: 113.9501 },
{ lat: -8.0698, lng: 113.9567 },
{ lat: -8.0645, lng: 113.9523 },
{ lat: -8.0689, lng: 113.9478 },
{ lat: -8.0723, lng: 113.9423 },
{ lat: -8.0656, lng: 113.9445 },
{ lat: -8.0712, lng: 113.9567 },
{ lat: -8.0678, lng: 113.9612 },
{ lat: -8.0623, lng: 113.9489 },
{ lat: -8.0667, lng: 113.9534 },
{ lat: -8.0723, lng: 113.9456 },
{ lat: -8.0689, lng: 113.9501 }
]
},
{
kecamatan: "Tanggul",
points: [
{ lat: -8.1645, lng: 113.4578 },
{ lat: -8.1689, lng: 113.4634 },
{ lat: -8.1656, lng: 113.4512 },
{ lat: -8.1712, lng: 113.4567 },
{ lat: -8.1623, lng: 113.4623 },
{ lat: -8.1678, lng: 113.4523 },
{ lat: -8.1634, lng: 113.4578 },
{ lat: -8.1689, lng: 113.4512 },
{ lat: -8.1734, lng: 113.4634 },
{ lat: -8.1667, lng: 113.4589 },
{ lat: -8.1612, lng: 113.4556 },
{ lat: -8.1723, lng: 113.4501 },
{ lat: -8.1645, lng: 113.4545 }
]
},
{
kecamatan: "Tempurejo",
points: [
{ lat: -8.3012, lng: 113.7123 },
{ lat: -8.3067, lng: 113.7187 },
{ lat: -8.3123, lng: 113.7156 },
{ lat: -8.2978, lng: 113.7098 },
{ lat: -8.3045, lng: 113.7234 },
{ lat: -8.3089, lng: 113.7156 },
{ lat: -8.3123, lng: 113.7098 },
{ lat: -8.2989, lng: 113.7176 },
{ lat: -8.3056, lng: 113.7056 },
{ lat: -8.3112, lng: 113.7267 },
{ lat: -8.3034, lng: 113.7223 },
{ lat: -8.2967, lng: 113.7134 },
{ lat: -8.3078, lng: 113.7089 }
]
},
{
kecamatan: "Umbulsari",
points: [
{ lat: -8.2534, lng: 113.4123 },
{ lat: -8.2578, lng: 113.4178 },
{ lat: -8.2623, lng: 113.4156 },
{ lat: -8.2556, lng: 113.4078 },
{ lat: -8.2589, lng: 113.4234 },
{ lat: -8.2634, lng: 113.4089 },
{ lat: -8.2567, lng: 113.4156 },
{ lat: -8.2612, lng: 113.4218 },
{ lat: -8.2534, lng: 113.4189 },
{ lat: -8.2589, lng: 113.4067 },
{ lat: -8.2545, lng: 113.4112 },
{ lat: -8.2623, lng: 113.4234 },
{ lat: -8.2578, lng: 113.4123 }
]
},
{
kecamatan: "Wuluhan",
points: [
{ lat: -8.3234, lng: 113.5234 },
{ lat: -8.3289, lng: 113.5289 },
{ lat: -8.3178, lng: 113.5167 },
{ lat: -8.3267, lng: 113.5123 },
{ lat: -8.3212, lng: 113.5289 },
{ lat: -8.3145, lng: 113.5212 },
{ lat: -8.3198, lng: 113.5156 },
{ lat: -8.3256, lng: 113.5345 },
{ lat: -8.3321, lng: 113.5278 },
{ lat: -8.3167, lng: 113.5312 },
{ lat: -8.3223, lng: 113.5156 },
{ lat: -8.3278, lng: 113.5198 },
{ lat: -8.3145, lng: 113.5267 },
{ lat: -8.3312, lng: 113.5123 }
]
}
];

View File

@ -89,5 +89,82 @@ export const resourcesData = [
attributes: { attributes: {
fields: ['id', 'action', 'resource_id', 'role_id', 'created_at', 'updated_at'] fields: ['id', 'action', 'resource_id', 'role_id', 'created_at', 'updated_at']
} }
},
{
name: 'units',
description: 'Police unit management',
attributes: {
fields: ['code_unit', 'district_id', 'name', 'description', 'type', 'created_at', 'updated_at', 'address', 'land_area', 'latitude', 'longitude', 'location', 'city_id', 'phone']
}
},
{
name: 'patrol_units',
description: 'Patrol unit management',
attributes: {
fields: ['id', 'unit_id', 'location_id', 'name', 'type', 'status', 'radius', 'created_at']
}
},
{
name: 'officers',
description: 'Police officer management',
attributes: {
fields: ['id', 'unit_id', 'role_id', 'nrp', 'name', 'rank', 'position', 'phone', 'email', 'valid_until', 'created_at', 'updated_at', 'avatar', 'qr_code', 'patrol_unitsId']
}
},
{
name: 'unit_statistics',
description: 'Unit statistics management',
attributes: {
fields: ['id', 'unit_id', 'year', 'month', 'crime_solved', 'crime_total', 'performance_index', 'created_at', 'updated_at']
}
},
{
name: 'incident_logs',
description: 'Incident logs management',
attributes: {
fields: ['id', 'user_id', 'location_id', 'category_id', 'description', 'source', 'time', 'verified', 'created_at', 'updated_at']
}
},
{
name: 'evidence',
description: 'Incident evidence management',
attributes: {
fields: ['id', 'incident_id', 'type', 'url', 'description', 'caption', 'metadata', 'uploaded_at']
}
},
{
name: 'events',
description: 'Events management',
attributes: {
fields: ['id', 'name', 'description', 'code', 'created_at', 'user_id']
}
},
{
name: 'sessions',
description: 'User session management',
attributes: {
fields: ['id', 'user_id', 'event_id', 'status', 'created_at']
}
},
{
name: 'locations',
description: 'Location data management',
attributes: {
fields: ['id', 'district_id', 'event_id', 'address', 'type', 'latitude', 'longitude', 'land_area', 'polygon', 'geometry', 'created_at', 'updated_at', 'location', 'distance_to_unit']
}
},
{
name: 'location_logs',
description: 'Location logs management',
attributes: {
fields: ['id', 'user_id', 'latitude', 'longitude', 'accuracy', 'heading', 'speed', 'altitude', 'created_at']
}
},
{
name: 'logs',
description: 'System logs management',
attributes: {
fields: ['id', 'action', 'entity', 'entity_id', 'details', 'ip_address', 'user_agent', 'created_at']
}
} }
]; ];

View File

@ -3,6 +3,10 @@ export const rolesData = [
name: 'admin', name: 'admin',
description: 'Administrator with full access to all features.', description: 'Administrator with full access to all features.',
}, },
{
name: 'officer',
description: 'Police officer with access to patrol and report features.',
},
{ {
name: 'viewer', name: 'viewer',
description: 'Read-only access to the data.', description: 'Read-only access to the data.',

View File

@ -0,0 +1,85 @@
-- CreateTable
CREATE TABLE "patrol_units" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"unit_id" VARCHAR(20) NOT NULL,
"location_id" UUID NOT NULL,
"name" VARCHAR(100) NOT NULL,
"type" VARCHAR(50) NOT NULL,
"status" VARCHAR(50) NOT NULL,
"radius" DOUBLE PRECISION NOT NULL,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "patrol_units_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "officers" (
"id" TEXT NOT NULL,
"unit_id" VARCHAR(20) NOT NULL,
"role_id" UUID NOT NULL,
"nrp" VARCHAR(100) NOT NULL,
"name" VARCHAR(100) NOT NULL,
"rank" VARCHAR(100),
"position" VARCHAR(100),
"phone" VARCHAR(20),
"email" VARCHAR(255),
"avatar" TEXT,
"valid_until" TIMESTAMP(3),
"qr_code" TEXT,
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
"patrol_unitsId" UUID,
CONSTRAINT "officers_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "idx_patrol_units_unit_id" ON "patrol_units"("unit_id");
-- CreateIndex
CREATE INDEX "idx_patrol_units_location_id" ON "patrol_units"("location_id");
-- CreateIndex
CREATE INDEX "idx_patrol_units_name" ON "patrol_units"("name");
-- CreateIndex
CREATE INDEX "idx_patrol_units_type" ON "patrol_units"("type");
-- CreateIndex
CREATE INDEX "idx_patrol_units_status" ON "patrol_units"("status");
-- CreateIndex
CREATE UNIQUE INDEX "officers_nrp_key" ON "officers"("nrp");
-- CreateIndex
CREATE INDEX "idx_officers_unit_id" ON "officers"("unit_id");
-- CreateIndex
CREATE INDEX "idx_officers_nrp" ON "officers"("nrp");
-- CreateIndex
CREATE INDEX "idx_officers_name" ON "officers"("name");
-- CreateIndex
CREATE INDEX "idx_officers_rank" ON "officers"("rank");
-- CreateIndex
CREATE INDEX "idx_officers_position" ON "officers"("position");
-- CreateIndex
CREATE INDEX "idx_units_location_district" ON "units"("district_id", "location");
-- AddForeignKey
ALTER TABLE "patrol_units" ADD CONSTRAINT "patrol_units_location_id_fkey" FOREIGN KEY ("location_id") REFERENCES "locations"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "patrol_units" ADD CONSTRAINT "patrol_units_unit_id_fkey" FOREIGN KEY ("unit_id") REFERENCES "units"("code_unit") ON DELETE CASCADE ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "officers" ADD CONSTRAINT "officers_unit_id_fkey" FOREIGN KEY ("unit_id") REFERENCES "units"("code_unit") ON DELETE CASCADE ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "officers" ADD CONSTRAINT "officers_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "officers" ADD CONSTRAINT "officers_patrol_unitsId_fkey" FOREIGN KEY ("patrol_unitsId") REFERENCES "patrol_units"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "evidence" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"incident_id" UUID NOT NULL,
"type" VARCHAR(50) NOT NULL,
"url" TEXT NOT NULL,
"uploaded_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "evidence_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "idx_evidence_incident_id" ON "evidence"("incident_id");
-- AddForeignKey
ALTER TABLE "evidence" ADD CONSTRAINT "evidence_incident_id_fkey" FOREIGN KEY ("incident_id") REFERENCES "incident_logs"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "evidence" ADD COLUMN "caption" VARCHAR(255),
ADD COLUMN "description" VARCHAR(255),
ADD COLUMN "metadata" JSONB;

View File

@ -0,0 +1,46 @@
/*
Warnings:
- The primary key for the `evidence` table will be changed. If it partially fails, the table could be left without primary key constraint.
- The primary key for the `officers` table will be changed. If it partially fails, the table could be left without primary key constraint.
- You are about to drop the column `patrol_unitsId` on the `officers` table. All the data in the column will be lost.
- The `id` column on the `officers` table would be dropped and recreated. This will lead to data loss if there is data in the column.
- The primary key for the `patrol_units` table will be changed. If it partially fails, the table could be left without primary key constraint.
- A unique constraint covering the columns `[id]` on the table `evidence` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[id]` on the table `patrol_units` will be added. If there are existing duplicate values, this will fail.
- Changed the type of `id` on the `evidence` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
- Added the required column `patrol_unit_id` to the `officers` table without a default value. This is not possible if the table is not empty.
- Changed the type of `id` on the `patrol_units` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
*/
-- DropForeignKey
ALTER TABLE "officers" DROP CONSTRAINT "officers_patrol_unitsId_fkey";
-- AlterTable
ALTER TABLE "evidence" DROP CONSTRAINT "evidence_pkey",
DROP COLUMN "id",
ADD COLUMN "id" VARCHAR(20) NOT NULL,
ADD CONSTRAINT "evidence_pkey" PRIMARY KEY ("id");
-- AlterTable
ALTER TABLE "officers" DROP CONSTRAINT "officers_pkey",
DROP COLUMN "patrol_unitsId",
ADD COLUMN "patrol_unit_id" VARCHAR(20) NOT NULL,
DROP COLUMN "id",
ADD COLUMN "id" UUID NOT NULL DEFAULT gen_random_uuid(),
ADD CONSTRAINT "officers_pkey" PRIMARY KEY ("id");
-- AlterTable
ALTER TABLE "patrol_units" DROP CONSTRAINT "patrol_units_pkey",
DROP COLUMN "id",
ADD COLUMN "id" VARCHAR(20) NOT NULL,
ADD CONSTRAINT "patrol_units_pkey" PRIMARY KEY ("id");
-- CreateIndex
CREATE UNIQUE INDEX "evidence_id_key" ON "evidence"("id");
-- CreateIndex
CREATE UNIQUE INDEX "patrol_units_id_key" ON "patrol_units"("id");
-- AddForeignKey
ALTER TABLE "officers" ADD CONSTRAINT "officers_patrol_unit_id_fkey" FOREIGN KEY ("patrol_unit_id") REFERENCES "patrol_units"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,19 @@
/*
Warnings:
- The primary key for the `patrol_units` table will be changed. If it partially fails, the table could be left without primary key constraint.
*/
-- DropForeignKey
ALTER TABLE "officers" DROP CONSTRAINT "officers_patrol_unit_id_fkey";
-- AlterTable
ALTER TABLE "officers" ALTER COLUMN "patrol_unit_id" SET DATA TYPE VARCHAR(100);
-- AlterTable
ALTER TABLE "patrol_units" DROP CONSTRAINT "patrol_units_pkey",
ALTER COLUMN "id" SET DATA TYPE VARCHAR(100),
ADD CONSTRAINT "patrol_units_pkey" PRIMARY KEY ("id");
-- AddForeignKey
ALTER TABLE "officers" ADD CONSTRAINT "officers_patrol_unit_id_fkey" FOREIGN KEY ("patrol_unit_id") REFERENCES "patrol_units"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -63,6 +63,7 @@ model roles {
updated_at DateTime @default(now()) @db.Timestamptz(6) updated_at DateTime @default(now()) @db.Timestamptz(6)
permissions permissions[] permissions permissions[]
users users[] users users[]
officers officers[]
} }
model sessions { model sessions {
@ -173,12 +174,12 @@ model crimes {
method String? @db.VarChar(100) method String? @db.VarChar(100)
month Int? month Int?
number_of_crime Int @default(0) number_of_crime Int @default(0)
crime_cleared Int @default(0)
avg_crime Float @default(0)
score Float @default(0) score Float @default(0)
updated_at DateTime? @default(now()) @db.Timestamptz(6) updated_at DateTime? @default(now()) @db.Timestamptz(6)
year Int? year Int?
source_type String? @db.VarChar(100) source_type String? @db.VarChar(100)
crime_cleared Int @default(0)
avg_crime Float @default(0)
crime_incidents crime_incidents[] crime_incidents crime_incidents[]
districts districts @relation(fields: [district_id], references: [id], onDelete: Cascade, onUpdate: NoAction) districts districts @relation(fields: [district_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
@ -230,20 +231,22 @@ model locations {
latitude Float latitude Float
longitude Float longitude Float
land_area Float? land_area Float?
distance_to_unit Float?
polygon Unsupported("geometry")? polygon Unsupported("geometry")?
geometry Unsupported("geometry")? geometry Unsupported("geometry")?
created_at DateTime? @default(now()) @db.Timestamptz(6) created_at DateTime? @default(now()) @db.Timestamptz(6)
updated_at DateTime? @default(now()) @db.Timestamptz(6) updated_at DateTime? @default(now()) @db.Timestamptz(6)
location Unsupported("geography") location Unsupported("geography")
distance_to_unit Float?
crime_incidents crime_incidents[] crime_incidents crime_incidents[]
incident_logs incident_logs[] incident_logs incident_logs[]
districts districts @relation(fields: [district_id], references: [id], onDelete: Cascade, onUpdate: NoAction) districts districts @relation(fields: [district_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
event events @relation(fields: [event_id], references: [id]) event events @relation(fields: [event_id], references: [id])
patrol_units patrol_units[]
@@index([district_id], map: "idx_locations_district_id") @@index([district_id], map: "idx_locations_district_id")
@@index([type], map: "idx_locations_type") @@index([type], map: "idx_locations_type")
@@index([location], map: "idx_locations_geography", type: Gist) @@index([location], map: "idx_locations_geography", type: Gist)
@@index([location], map: "idx_locations_location_gist", type: Gist)
} }
model incident_logs { model incident_logs {
@ -260,40 +263,110 @@ model incident_logs {
crime_categories crime_categories @relation(fields: [category_id], references: [id], map: "fk_incident_category") crime_categories crime_categories @relation(fields: [category_id], references: [id], map: "fk_incident_category")
locations locations @relation(fields: [location_id], references: [id], onDelete: Cascade, onUpdate: NoAction) locations locations @relation(fields: [location_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
user users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction) user users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
evidence evidence[]
@@index([category_id], map: "idx_incident_logs_category_id") @@index([category_id], map: "idx_incident_logs_category_id")
@@index([time], map: "idx_incident_logs_time") @@index([time], map: "idx_incident_logs_time")
} }
model evidence {
id String @id @unique @db.VarChar(20)
incident_id String @db.Uuid
type String @db.VarChar(50) // contoh: photo, video, document, images
url String @db.Text
description String? @db.VarChar(255)
caption String? @db.VarChar(255)
metadata Json?
uploaded_at DateTime? @default(now()) @db.Timestamptz(6)
incident incident_logs @relation(fields: [incident_id], references: [id], onDelete: Cascade)
@@index([incident_id], map: "idx_evidence_incident_id")
}
model units { model units {
code_unit String @id @unique @db.VarChar(20) code_unit String @id @unique @db.VarChar(20)
district_id String? @unique @db.VarChar(20) district_id String? @unique @db.VarChar(20)
city_id String @db.VarChar(20)
name String @db.VarChar(100) name String @db.VarChar(100)
description String? description String?
type unit_type type unit_type
created_at DateTime? @default(now()) @db.Timestamptz(6) created_at DateTime? @default(now()) @db.Timestamptz(6)
updated_at DateTime? @default(now()) @db.Timestamptz(6) updated_at DateTime? @default(now()) @db.Timestamptz(6)
address String? address String?
phone String?
land_area Float? land_area Float?
latitude Float latitude Float
longitude Float longitude Float
location Unsupported("geography") location Unsupported("geography")
city_id String @db.VarChar(20)
phone String?
unit_statistics unit_statistics[] 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) cities cities @relation(fields: [city_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
districts districts? @relation(fields: [district_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
officers officers[]
patrol_units patrol_units[]
@@index([name], map: "idx_units_name") @@index([name], map: "idx_units_name")
@@index([type], map: "idx_units_type") @@index([type], map: "idx_units_type")
@@index([code_unit], map: "idx_units_code_unit") @@index([code_unit], map: "idx_units_code_unit")
@@index([district_id], map: "idx_units_district_id") @@index([district_id], map: "idx_units_district_id")
@@index([location], map: "idx_unit_location", type: Gist) @@index([location], map: "idx_unit_location", type: Gist)
@@index([district_id, location], map: "idx_units_location_district")
@@index([location], map: "idx_units_location_gist", type: Gist)
@@index([location], type: Gist)
@@index([location], map: "units_location_idx1", type: Gist)
@@index([location], map: "units_location_idx2", type: Gist)
}
model patrol_units {
id String @id @unique @db.VarChar(100)
unit_id String @db.VarChar(20)
location_id String @db.Uuid
name String @db.VarChar(100)
type String @db.VarChar(50)
status String @db.VarChar(50)
radius Float
created_at DateTime @default(now()) @db.Timestamptz(6)
members officers[]
location locations @relation(fields: [location_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
unit units @relation(fields: [unit_id], references: [code_unit], onDelete: Cascade, onUpdate: NoAction)
@@index([unit_id], map: "idx_patrol_units_unit_id")
@@index([location_id], map: "idx_patrol_units_location_id")
@@index([name], map: "idx_patrol_units_name")
@@index([type], map: "idx_patrol_units_type")
@@index([status], map: "idx_patrol_units_status")
}
model officers {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
unit_id String @db.VarChar(20)
role_id String @db.Uuid
patrol_unit_id String @db.VarChar(100)
nrp String @unique @db.VarChar(100)
name String @db.VarChar(100)
rank String? @db.VarChar(100)
position String? @db.VarChar(100)
phone String? @db.VarChar(100)
email String? @db.VarChar(255)
avatar String?
valid_until DateTime?
qr_code String?
created_at DateTime? @default(now()) @db.Timestamptz(6)
updated_at DateTime? @default(now()) @db.Timestamptz(6)
units units @relation(fields: [unit_id], references: [code_unit], onDelete: Cascade, onUpdate: NoAction)
roles roles @relation(fields: [role_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
patrol_units patrol_units? @relation(fields: [patrol_unit_id], references: [id])
@@index([unit_id], map: "idx_officers_unit_id")
@@index([nrp], map: "idx_officers_nrp")
@@index([name], map: "idx_officers_name")
@@index([rank], map: "idx_officers_rank")
@@index([position], map: "idx_officers_position")
} }
model unit_statistics { model unit_statistics {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
code_unit String @db.VarChar(20)
crime_total Int crime_total Int
crime_cleared Int crime_cleared Int
percentage Float? percentage Float?
@ -302,6 +375,7 @@ model unit_statistics {
year Int year Int
created_at DateTime? @default(now()) @db.Timestamptz(6) created_at DateTime? @default(now()) @db.Timestamptz(6)
updated_at DateTime? @default(now()) @db.Timestamptz(6) updated_at DateTime? @default(now()) @db.Timestamptz(6)
code_unit String @db.VarChar(20)
units units @relation(fields: [code_unit], references: [code_unit], onDelete: Cascade, onUpdate: NoAction) units units @relation(fields: [code_unit], references: [code_unit], onDelete: Cascade, onUpdate: NoAction)
@@unique([code_unit, month, year]) @@unique([code_unit, month, year])

View File

@ -9,6 +9,8 @@ import { DemographicsSeeder } from './seeds/demographic';
import { CrimeCategoriesSeeder } from './seeds/crime-category'; import { CrimeCategoriesSeeder } from './seeds/crime-category';
import { UnitSeeder } from './seeds/units'; import { UnitSeeder } from './seeds/units';
import { PatrolUnitsSeeder } from './seeds/patrol-units';
import { OfficersSeeder } from './seeds/officers';
import { CrimesSeeder } from './seeds/crimes'; import { CrimesSeeder } from './seeds/crimes';
import { CrimeIncidentsByUnitSeeder } from './seeds/crime-incidents'; import { CrimeIncidentsByUnitSeeder } from './seeds/crime-incidents';
import { CrimeIncidentsByTypeSeeder } from './seeds/crime-incidents-cbt'; import { CrimeIncidentsByTypeSeeder } from './seeds/crime-incidents-cbt';
@ -31,16 +33,18 @@ class DatabaseSeeder {
// Daftar semua seeders di sini // Daftar semua seeders di sini
this.seeders = [ this.seeders = [
new RoleSeeder(prisma), // new RoleSeeder(prisma),
new ResourceSeeder(prisma), // new ResourceSeeder(prisma),
new PermissionSeeder(prisma), // new PermissionSeeder(prisma),
new CrimeCategoriesSeeder(prisma), // new CrimeCategoriesSeeder(prisma),
new GeoJSONSeeder(prisma), // new GeoJSONSeeder(prisma),
new UnitSeeder(prisma), // new UnitSeeder(prisma),
new DemographicsSeeder(prisma), // new PatrolUnitsSeeder(prisma),
new CrimesSeeder(prisma), // new OfficersSeeder(prisma),
// new DemographicsSeeder(prisma),
// new CrimesSeeder(prisma),
// new CrimeIncidentsByUnitSeeder(prisma), // new CrimeIncidentsByUnitSeeder(prisma),
new CrimeIncidentsByTypeSeeder(prisma), // new CrimeIncidentsByTypeSeeder(prisma),
new IncidentLogSeeder(prisma), new IncidentLogSeeder(prisma),
]; ];
} }

View File

@ -13,6 +13,8 @@ import * as path from "path";
import { CRegex } from "../../app/_utils/const/regex"; import { CRegex } from "../../app/_utils/const/regex";
import { districtCenters } from "../data/jsons/district-center"; import { districtCenters } from "../data/jsons/district-center";
import { districtsGeoJson } from "../data/geojson/jember/districts-geojson";
type ICreateLocations = { type ICreateLocations = {
id: string; id: string;
@ -389,12 +391,10 @@ export class CrimeIncidentsByTypeSeeder {
`${placeType} ${district.name}, ${streetName}, Jember`; `${placeType} ${district.name}, ${streetName}, Jember`;
break; break;
case 2: case 2:
randomAddress = `${streetName} Blok ${ randomAddress = `${streetName} Blok ${String.fromCharCode(
String.fromCharCode(
65 + Math.floor(Math.random() * 26), 65 + Math.floor(Math.random() * 26),
) )
}-${ }-${Math.floor(Math.random() * 20) + 1
Math.floor(Math.random() * 20) + 1
}, ${district.name}, Jember`; }, ${district.name}, Jember`;
break; break;
} }
@ -425,7 +425,7 @@ export class CrimeIncidentsByTypeSeeder {
separator: "-", separator: "-",
uniquenessStrategy: "counter", uniquenessStrategy: "counter",
}, },
CRegex.CR_YEAR_SEQUENCE, CRegex.FORMAT_ID_YEAR_SEQUENCE,
); );
const status = resolvedCount < crimesCleared const status = resolvedCount < crimesCleared
@ -717,30 +717,129 @@ export class CrimeIncidentsByTypeSeeder {
const points = []; const points = [];
const districtNameLower = districtName.toLowerCase(); const districtNameLower = districtName.toLowerCase();
// Find the district feature in the GeoJSON
const districtFeature = districtsGeoJson.features.find(
(feature) =>
feature.properties &&
feature.properties.kecamatan &&
feature.properties.kecamatan.toLowerCase() === districtNameLower
);
// Helper to flatten all coordinates from a GeoJSON geometry
function extractCoordinates(geometry: any): Array<number[]> {
if (!geometry) return [];
if (geometry.type === "Polygon") {
// geometry.coordinates: [ [ [lng, lat, z?], ... ] ]
return geometry.coordinates.flat();
}
if (geometry.type === "MultiPolygon") {
// geometry.coordinates: [ [ [ [lng, lat, z?], ... ] ], ... ]
return geometry.coordinates.flat(2);
}
return [];
}
// Use coordinates from GeoJSON if available
if (districtFeature && districtFeature.geometry) {
// console.log(`Found GeoJSON for district: ${districtName}`);
const allCoords = extractCoordinates(districtFeature.geometry);
// Filter valid coordinates (handle both 2D and 3D coordinates)
const coords = allCoords.filter(c =>
Array.isArray(c) &&
c.length >= 2 &&
typeof c[0] === 'number' &&
typeof c[1] === 'number'
);
if (coords.length === 0) {
console.warn(`No valid coordinates found in GeoJSON for district: ${districtName}`);
console.log("Geometry structure:", JSON.stringify(districtFeature.geometry, null, 2));
} else {
// console.log(`Found ${coords.length} valid coordinates for district: ${districtName}`);
// If enough points, sample randomly; otherwise, interpolate
if (numPoints <= coords.length) {
// Shuffle and pick numPoints
const shuffled = [...coords].sort(() => 0.5 - Math.random());
for (let i = 0; i < numPoints; i++) {
const coord = shuffled[i];
// Extract lng and lat (first two values), regardless of whether it's 2D or 3D
const lng = coord[0];
const lat = coord[1];
const noise = 0.0002 * (Math.random() - 0.5);
points.push({
latitude: lat + noise,
longitude: lng + noise,
radius: 100 + Math.random() * 400,
});
}
} else {
// Use all points, then interpolate between random pairs for the rest
coords.forEach((coord) => {
const lng = coord[0];
const lat = coord[1];
const noise = 0.0002 * (Math.random() - 0.5);
points.push({
latitude: lat + noise,
longitude: lng + noise,
radius: 100 + Math.random() * 400,
});
});
for (let i = coords.length; i < numPoints; i++) {
// Pick two random points and interpolate
const idx1 = Math.floor(Math.random() * coords.length);
let idx2 = Math.floor(Math.random() * coords.length);
if (idx2 === idx1) idx2 = (idx2 + 1) % coords.length;
const coord1 = coords[idx1];
const coord2 = coords[idx2];
const lng1 = coord1[0];
const lat1 = coord1[1];
const lng2 = coord2[0];
const lat2 = coord2[1];
const t = Math.random();
const noise = 0.0003 * (Math.random() - 0.5);
points.push({
latitude: lat1 * t + lat2 * (1 - t) + noise,
longitude: lng1 * t + lng2 * (1 - t) + noise,
radius: 100 + Math.random() * 400,
});
}
}
return points;
}
} else {
console.warn(`No GeoJSON feature found for district: ${districtName}`);
}
// Fallback: use district center if GeoJSON not available
const districtCenter = districtCenters.find( const districtCenter = districtCenters.find(
(center) => center.kecamatan.toLowerCase() === districtNameLower, (center) => center.kecamatan.toLowerCase() === districtNameLower,
); );
if (!districtCenter) { if (districtCenter) {
return []; console.log(`Using district center fallback for: ${districtName}`);
}
const centerLat = districtCenter.lat; const centerLat = districtCenter.lat;
const centerLng = districtCenter.lng; const centerLng = districtCenter.lng;
const estimatedRadiusKm = Math.sqrt(landArea / Math.PI) / 1000;
// Skala kecil: radius hanya 0.5 km dari center (sekitar 500 meter) const radiusKm = Math.min(3, Math.max(0.5, estimatedRadiusKm));
const radiusKm = 0.5;
const radiusDeg = radiusKm / 111; const radiusDeg = radiusKm / 111;
for (let i = 0; i < numPoints; i++) { for (let i = 0; i < numPoints; i++) {
const angle = Math.random() * 2 * Math.PI; const angle = Math.random() * 2 * Math.PI;
// Jarak random, lebih padat di tengah
const distance = Math.pow(Math.random(), 1.5) * radiusDeg; const distance = Math.pow(Math.random(), 1.5) * radiusDeg;
const latitude = centerLat + distance * Math.cos(angle); const latitude = centerLat + distance * Math.cos(angle);
const longitude = centerLng + const longitude = centerLng +
distance * Math.sin(angle) / Math.cos(centerLat * Math.PI / 180); distance * Math.sin(angle) / Math.cos(centerLat * Math.PI / 180);
const pointRadius = distance * 111000; const pointRadius = distance * 111000;
points.push({ points.push({
@ -749,6 +848,9 @@ export class CrimeIncidentsByTypeSeeder {
radius: pointRadius, radius: pointRadius,
}); });
} }
} else {
console.error(`No data available for district: ${districtName}`);
}
return points; return points;
} }

View File

@ -548,7 +548,7 @@ private generateDistributedPoints(
separator: '-', separator: '-',
uniquenessStrategy: 'counter', uniquenessStrategy: 'counter',
}, },
CRegex.CR_YEAR_SEQUENCE CRegex.FORMAT_ID_YEAR_SEQUENCE
); );
// Determine status based on crime_cleared // Determine status based on crime_cleared

View File

@ -227,7 +227,7 @@ export class CrimesSeeder {
separator: "-", separator: "-",
uniquenessStrategy: "counter", uniquenessStrategy: "counter",
}, },
CRegex.CR_YEAR_SEQUENCE, CRegex.FORMAT_ID_YEAR_SEQUENCE,
); );
crimesData.push({ crimesData.push({
@ -311,7 +311,7 @@ export class CrimesSeeder {
separator: "-", separator: "-",
uniquenessStrategy: "counter", uniquenessStrategy: "counter",
}, },
CRegex.CR_YEAR_SEQUENCE, CRegex.FORMAT_ID_YEAR_SEQUENCE,
); );
crimesData.push({ crimesData.push({
@ -395,7 +395,7 @@ export class CrimesSeeder {
separator: "-", separator: "-",
uniquenessStrategy: "counter", uniquenessStrategy: "counter",
}, },
CRegex.CR_SEQUENCE_END, CRegex.FORMAT_ID_SEQUENCE_END,
); );
crimesData.push({ crimesData.push({
@ -473,7 +473,7 @@ export class CrimesSeeder {
separator: "-", separator: "-",
uniquenessStrategy: "counter", uniquenessStrategy: "counter",
}, },
CRegex.CR_YEAR_SEQUENCE, CRegex.FORMAT_ID_YEAR_SEQUENCE,
); );
crimesData.push({ crimesData.push({
@ -555,7 +555,7 @@ export class CrimesSeeder {
separator: "-", separator: "-",
uniquenessStrategy: "counter", uniquenessStrategy: "counter",
}, },
CRegex.CR_YEAR_SEQUENCE, CRegex.FORMAT_ID_YEAR_SEQUENCE,
); );
crimesData.push({ crimesData.push({
@ -631,7 +631,7 @@ export class CrimesSeeder {
separator: "-", separator: "-",
uniquenessStrategy: "counter", uniquenessStrategy: "counter",
}, },
CRegex.CR_SEQUENCE_END, CRegex.FORMAT_ID_SEQUENCE_END,
); );
crimesData.push({ crimesData.push({

View File

@ -1,9 +1,11 @@
import { PrismaClient } from "@prisma/client"; import { evidence, PrismaClient } from "@prisma/client";
import { faker } from "@faker-js/faker"; import { faker } from "@faker-js/faker";
import { districtCenters } from "../data/jsons/district-center"; import { districtCenters } from "../data/jsons/district-center";
import { createClient } from "../../app/_utils/supabase/client"; import { createClient } from "../../app/_utils/supabase/client";
import db from "../db"; import db from "../db";
import { generateId, generateIdWithDbCounter } from "../../app/_utils/common";
import { CRegex } from "../../app/_utils/const/regex";
export class IncidentLogSeeder { export class IncidentLogSeeder {
constructor( constructor(
@ -168,6 +170,7 @@ export class IncidentLogSeeder {
// Create 24 incidents data array // Create 24 incidents data array
const incidentData = []; const incidentData = [];
const evidenceData = [];
for (let i = 0; i < 24; i++) { for (let i = 0; i < 24; i++) {
const hourOffset = this.getRandomInt(0, 24); // Random hour within last 24 hours const hourOffset = this.getRandomInt(0, 24); // Random hour within last 24 hours
@ -180,7 +183,10 @@ export class IncidentLogSeeder {
const category = const category =
categories[Math.floor(Math.random() * categories.length)]; categories[Math.floor(Math.random() * categories.length)];
// Create incident data
const incidentId = faker.string.uuid();
incidentData.push({ incidentData.push({
id: incidentId, // Generate ID here to reference in evidence
user_id: userId, user_id: userId,
location_id: location.id, location_id: location.id,
category_id: category.id, category_id: category.id,
@ -189,6 +195,39 @@ export class IncidentLogSeeder {
source: Math.random() > 0.3 ? "resident" : "reporter", source: Math.random() > 0.3 ? "resident" : "reporter",
verified: Math.random() > 0.5, verified: Math.random() > 0.5,
}); });
// Generate 1-3 evidence items per incident
const numEvidenceItems = this.getRandomInt(1, 3);
for (let j = 0; j < numEvidenceItems; j++) {
const evidenceType = this.getRandomEvidenceType();
// Make sure metadata is a proper Prisma InputJsonValue
const metadata = JSON.stringify(this.generateRandomMetadata(evidenceType));
const newEvidenceId = await generateIdWithDbCounter(
"evidence",
{
prefix: "EV",
segments: {
sequentialDigits: 4,
},
format: "{prefix}-{sequence}",
separator: "-",
uniquenessStrategy: "counter"
},
CRegex.FORMAT_ID_SEQUENCE
);
evidenceData.push({
id: newEvidenceId,
incident_id: incidentId,
type: evidenceType,
url: this.getRandomFileUrl(evidenceType),
description: this.getRandomEvidenceDescription(),
caption: faker.lorem.sentence(),
metadata: metadata as any,
uploaded_at: timestamp,
});
}
} }
// Bulk insert all incidents at once // Bulk insert all incidents at once
@ -198,49 +237,125 @@ export class IncidentLogSeeder {
console.log(`Created ${createdIncidents.count} incident logs in bulk`); console.log(`Created ${createdIncidents.count} incident logs in bulk`);
// If you need the actual created records, query them after creation // Insert evidence for all incidents
const incidents = await this.prisma.incident_logs.findMany({ if (evidenceData.length > 0) {
where: { try {
user_id: userId, const createdEvidence = await this.prisma.evidence.createMany({
time: { data: evidenceData,
gte: new Date(now.getTime() - 24 * 60 * 60 * 1000),
},
},
orderBy: {
time: "asc",
},
}); });
console.log(`Created ${createdEvidence.count} evidence items`);
return incidents; } catch (error) {
console.error("Error creating evidence:", error);
}
} }
// Helper methods // If you need the actual created records, query them after creation
private getRandomInt(min: number, max: number): number { // const incidents = await this.prisma.incident_logs.findMany({
return Math.floor(Math.random() * (max - min + 1)) + min; // where: {
// user_id: userId,
// time: {
// gte: new Date(now.getTime() - 24 * 60 * 60 * 1000),
// },
// },
// include: {
// evidence: true, // Include evidence in the results
// },
// orderBy: {
// time: "asc",
// },
// });
// return incidents;
} }
private getRandomIncidentDescription(): string { // New helper methods for evidence generation
private getRandomEvidenceType(): string {
const types = ['image', 'video', 'audio', 'document'];
return types[Math.floor(Math.random() * types.length)];
}
private getRandomFileUrl(type: string): string {
const fileTypes: Record<string, string[]> = {
'image': ['.jpg', '.png', '.jpeg'],
'video': ['.mp4', '.mov', '.avi'],
'audio': ['.mp3', '.wav', '.ogg'],
'document': ['.pdf', '.docx', '.txt']
};
// Pick a random extension for that type
const extensions = fileTypes[type] || fileTypes['image'];
const extension = extensions[Math.floor(Math.random() * extensions.length)];
// Generate a fake file URL
if (type === 'image') {
return faker.image.url();
} else {
// For other types, create a plausible URL
const fileName = faker.system.fileName().replace(/\.\w+$/, '') + extension;
return `https://evidence-storage.sigap.com/${faker.string.uuid()}/${fileName}`;
}
}
private getRandomEvidenceDescription(): string {
const descriptions = [ const descriptions = [
"Suspicious person loitering in the area", "Photo of the suspect",
"Vehicle break-in reported", "CCTV footage of the incident",
"Shoplifting incident at local store", "Audio recording of the witness statement",
"Noise complaint from neighbors", "Police report document",
"Traffic accident with minor injuries", "Screenshot of online threat",
"Vandalism to public property", "Image of damaged property",
"Domestic dispute reported", "Photo of the crime scene",
"Trespassing on private property", "Video of the incident in progress",
"Armed robbery at convenience store", "Documentary evidence supporting the claim",
"Drug-related activity observed", "Witness photograph",
"Assault reported outside nightclub", "Receipt related to the incident",
"Missing person report filed", "Official complaint document",
"Public intoxication incident", "Map of incident location with notes",
"Package theft from doorstep", "Audio interview with victim",
"Illegal dumping observed", "Security footage timestamp"
]; ];
return descriptions[Math.floor(Math.random() * descriptions.length)]; return descriptions[Math.floor(Math.random() * descriptions.length)];
} }
private generateRandomMetadata(type: string): object {
// Generate random metadata based on evidence type
switch (type) {
case 'image':
return {
width: faker.number.int({ min: 800, max: 3000 }),
height: faker.number.int({ min: 600, max: 2000 }),
size: faker.number.int({ min: 100000, max: 5000000 }),
format: faker.helpers.arrayElement(['jpg', 'png', 'jpeg']),
location: {
latitude: faker.location.latitude(),
longitude: faker.location.longitude(),
}
};
case 'video':
return {
duration: faker.number.float({ min: 5, max: 180, fractionDigits: 1 }),
size: faker.number.int({ min: 1000000, max: 50000000 }),
resolution: faker.helpers.arrayElement(['720p', '1080p', '4K']),
format: faker.helpers.arrayElement(['mp4', 'mov', 'avi']),
};
case 'audio':
return {
duration: faker.number.float({ min: 10, max: 300, fractionDigits: 1 }),
size: faker.number.int({ min: 500000, max: 10000000 }),
format: faker.helpers.arrayElement(['mp3', 'wav', 'ogg']),
};
case 'document':
return {
pages: faker.number.int({ min: 1, max: 20 }),
size: faker.number.int({ min: 50000, max: 2000000 }),
format: faker.helpers.arrayElement(['pdf', 'docx', 'txt']),
};
default:
return {};
}
}
/** /**
* Generates a random point within a specified radius from a center point * Generates a random point within a specified radius from a center point
* @param centerLat Center latitude * @param centerLat Center latitude
@ -282,4 +397,31 @@ export class IncidentLogSeeder {
longitude: randomLng, longitude: randomLng,
}; };
} }
// Helper methods
private getRandomInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
private getRandomIncidentDescription(): string {
const descriptions = [
"Suspicious person loitering in the area",
"Vehicle break-in reported",
"Shoplifting incident at local store",
"Noise complaint from neighbors",
"Traffic accident with minor injuries",
"Vandalism to public property",
"Domestic dispute reported",
"Trespassing on private property",
"Armed robbery at convenience store",
"Drug-related activity observed",
"Assault reported outside nightclub",
"Missing person report filed",
"Public intoxication incident",
"Package theft from doorstep",
"Illegal dumping observed",
];
return descriptions[Math.floor(Math.random() * descriptions.length)];
}
} }

View File

@ -0,0 +1,300 @@
import { officers, PrismaClient } from '@prisma/client';
import { createClient } from '../../app/_utils/supabase/client';
import { faker } from '@faker-js/faker';
import * as crypto from 'crypto';
import { CRegex } from '../../app/_utils/const/regex';
import { generateIdWithDbCounter } from '../../app/_utils/common';
const RANKS = [
'IPDA', 'IPTU', 'AKP', 'KOMPOL', 'AKBP', 'KOMBES', // Officers
'AIPDA', 'AIPTU', 'BRIGADIR', 'BRIGADIR KEPALA', // Non-commissioned officers
'BRIPTU', 'BRIPKA', 'BRIPDA', // Lower ranks
];
const POSITIONS = [
'Kapolsek', 'Wakapolsek', 'Kanit Reskrim', 'Kanit Intel', 'Kanit Sabhara',
'Kanit Binmas', 'Kanit Provost', 'Anggota', 'Penyidik', 'Pengemban Fungsi',
'Kepala Unit Patroli', 'Komandan Sektor', 'Staf Operasional'
];
/**
* Generates a QR code value based on officer's NRP and unit ID
* The generated string can be used as input for QR code generation
*
* @param nrp Officer NRP (ID number)
* @param unitId Unit identifier
* @returns Encoded string to be used as QR code content
*/
function generateOfficerQRCode(nrp: string, unitId: string): string {
// Create a unique string by combining NRP and unit ID
const baseString = `SIGAP-OFFICER:${nrp}:${unitId}:${Date.now()}`;
// Create a hash of this string for security
const hash = crypto.createHash('sha256')
.update(baseString)
.digest('hex')
.substring(0, 12);
// Create a URL-friendly encoded string that includes officer info and validation hash
const qrData = Buffer.from(`${nrp}:${unitId}:${hash}`).toString('base64');
return qrData;
}
export class OfficersSeeder {
constructor(
private prisma: PrismaClient,
private supabase = createClient()
) { }
async run(): Promise<void> {
console.log('👮 Seeding officers...');
// First, let's clear existing officers
try {
await this.prisma.officers.deleteMany({});
console.log('✅ Removed existing officers');
} catch (error) {
console.error('❌ Error removing existing officers:', error);
}
// Get all police units
const policeUnits = await this.prisma.units.findMany({
select: {
code_unit: true,
name: true,
type: true,
patrol_units: {
select: {
id: true,
unit_id: true,
name: true,
},
}
},
});
// Get all patrol units
const patrolUnits = await this.prisma.patrol_units.findMany({
select: {
id: true,
unit_id: true,
name: true,
unit: {
select: {
code_unit: true,
name: true,
type: true,
},
}
},
});
if (!policeUnits.length) {
console.error('❌ No police units found. Please seed units first.');
return;
}
if (!patrolUnits.length) {
console.error('❌ No patrol units found. Please seed patrol units first.');
return;
}
// Create a mapping of unit_id to a list of patrol_unit_ids
const unitToPatrolUnits: Record<string, string[]> = {};
for (const patrol of patrolUnits) {
if (!unitToPatrolUnits[patrol.unit_id]) {
unitToPatrolUnits[patrol.unit_id] = [];
}
unitToPatrolUnits[patrol.unit_id].push(patrol.id);
}
// Check if each police unit has at least one patrol unit
for (const unit of policeUnits) {
if (!unitToPatrolUnits[unit.code_unit] || unitToPatrolUnits[unit.code_unit].length === 0) {
console.warn(`⚠️ Unit ${unit.name} (${unit.code_unit}) has no patrol units. Creating one...`);
// // Create a default patrol unit for this police unit
// try {
// // Mapping type to code
// const typeCodeMap: Record<string, string> = {
// car: "C",
// motorcycle: "M",
// foot: "F",
// mixed: "X",
// drone: "D",
// };
// const typeCode = typeCodeMap[patrolType] || "P";
// const codeUnitLast2 = unit.code_unit.slice(-2);
// const newId = await generateIdWithDbCounter(
// "patrol_units",
// {
// prefix: "PU",
// segments: {
// codes: [typeCode + codeUnitLast2],
// sequentialDigits: 2,
// },
// format: "{prefix}-{codes}{sequence}",
// },
// CRegex.PATROL_UNIT_ID_REGEX
// );
// const newPatrolUnit = await this.prisma.patrol_units.create({
// data: {
// unit_id: unit.code_unit,
// name: `Default Patrol Unit - ${unit.name}`,
// created_at: new Date(),
// }
// });
// if (!unitToPatrolUnits[unit.code_unit]) {
// unitToPatrolUnits[unit.code_unit] = [];
// }
// unitToPatrolUnits[unit.code_unit].push(newPatrolUnit.id);
// console.log(`✅ Created default patrol unit for ${unit.name}`);
// } catch (err) {
// console.error(`❌ Failed to create default patrol unit for ${unit.name}:`, err);
// // Skip this unit if we can't create a patrol unit
// continue;
// }
}
}
// Get officer role ID
const officerRole = await this.prisma.roles.findFirst({
where: { name: 'officer' },
select: { id: true },
});
if (!officerRole) {
console.error('❌ Officer role not found. Please seed roles first.');
return;
}
const roleId = officerRole.id;
const officers: Partial<officers>[] = [];
// Generate officers for each police unit
for (const unit of policeUnits) {
const patrolUnitIds = unitToPatrolUnits[unit.code_unit];
// Skip unit if there are no patrol units available
if (!patrolUnitIds || patrolUnitIds.length === 0) {
console.warn(`⚠️ Skipping unit ${unit.name} because it has no patrol units`);
continue;
}
// Number of officers varies by unit type
const officerCount = unit.type === 'polres' ?
faker.number.int({ min: 20, max: 30 }) :
faker.number.int({ min: 10, max: 20 });
// Keep track of assigned positions to avoid duplicates
const assignedPositions = new Set();
for (let i = 1; i <= officerCount; i++) {
// Generate a unique NRP (ID number)
const nrpYear = faker.number.int({ min: 80, max: 99 }).toString();
const nrpSeq = faker.number.int({ min: 10000, max: 99999 }).toString();
const nrp = `${nrpYear}${nrpSeq}`;
// Choose rank based on position
let position, rank;
// For important positions, assign specific ranks
if (i <= 5 && !assignedPositions.has('Kapolsek')) {
position = 'Kapolsek';
rank = faker.helpers.arrayElement(['IPTU', 'AKP', 'KOMPOL']);
assignedPositions.add(position);
} else if (i <= 5 && !assignedPositions.has('Wakapolsek')) {
position = 'Wakapolsek';
rank = faker.helpers.arrayElement(['IPDA', 'IPTU', 'AKP']);
assignedPositions.add(position);
} else if (i <= 5 && !assignedPositions.has('Kanit Reskrim')) {
position = 'Kanit Reskrim';
rank = faker.helpers.arrayElement(['IPDA', 'IPTU']);
assignedPositions.add(position);
} else {
// For other officers, assign random positions and ranks
position = faker.helpers.arrayElement(POSITIONS.filter(p =>
p !== 'Kapolsek' && p !== 'Wakapolsek' && p !== 'Kanit Reskrim' || !assignedPositions.has(p)
));
rank = faker.helpers.arrayElement(RANKS);
}
// Generate QR code
const qrCode = generateOfficerQRCode(nrp, unit.code_unit);
// Assign a default patrol unit (will be potentially reassigned later)
// IMPORTANT: Set a default patrol_unit_id to ensure all officers have one
const defaultPatrolUnitId = faker.helpers.arrayElement(patrolUnitIds);
// Create a new officer with a required patrol_unit_id
const officer = {
unit_id: unit.code_unit,
role_id: roleId,
nrp: nrp,
name: faker.person.fullName(),
rank: rank,
position: position,
phone: faker.helpers.fromRegExp(/08[0-9]{8,12}/), // Keep original format
email: faker.internet.email().toLowerCase(),
valid_until: faker.date.future(),
created_at: faker.date.past(),
updated_at: new Date(),
avatar: faker.image.personPortrait(), // Keep original format
qr_code: qrCode,
patrol_unit_id: defaultPatrolUnitId, // Default assignment
};
officers.push(officer);
}
}
// Insert officers in smaller batches
if (officers.length > 0) {
const batchSize = 100;
for (let i = 0; i < officers.length; i += batchSize) {
const batch = officers.slice(i, i + batchSize);
try {
// Ensure all officers in the batch have patrol_unit_id
const validBatch = batch.filter(officer => officer.patrol_unit_id);
if (validBatch.length !== batch.length) {
console.warn(`⚠️ Filtered out ${batch.length - validBatch.length} officers without patrol_unit_id`);
}
if (validBatch.length === 0) {
console.warn(`⚠️ Skipping empty batch ${i / batchSize + 1}`);
continue;
}
const { error } = await this.supabase
.from('officers')
.insert(validBatch)
.select();
if (error) {
console.error(`Error inserting officers batch ${i / batchSize + 1}:`, error);
} else {
console.log(`✅ Inserted batch ${i / batchSize + 1} (${validBatch.length} officers)`);
}
// Small delay between batches
await new Promise(resolve => setTimeout(resolve, 300));
} catch (err) {
console.error(`Exception when inserting officers batch ${i / batchSize + 1}:`, err);
}
}
console.log(`👮 Created ${officers.length} officers for ${policeUnits.length} police units`);
} else {
console.warn('⚠️ No officer data to insert');
}
}
}

View File

@ -0,0 +1,455 @@
import { PrismaClient } from '@prisma/client';
import { createClient } from '../../app/_utils/supabase/client';
import { faker } from '@faker-js/faker';
import { generateIdWithDbCounter } from "../../app/_utils/common";
import { districtsGeoJson } from '../data/geojson/jember/districts-geojson';
import { CRegex } from '../../app/_utils/const/regex';
export class PatrolUnitsSeeder {
constructor(
private prisma: PrismaClient,
private supabase = createClient()
) { }
async run(): Promise<void> {
console.log('🚓 Seeding patrol units...');
// First, let's clear existing patrol units
try {
await this.prisma.patrol_units.deleteMany({});
// Also delete from Supabase to maintain consistency
await this.supabase.from('patrol_units').delete().neq('id', 'dummy');
console.log('✅ Removed existing patrol units');
} catch (error) {
console.error('❌ Error removing existing patrol units:', error);
return; // Exit if we can't clean up properly
}
// Make sure we have a user and event
const event = await this.ensureEventAndSession();
if (!event) {
console.error("❌ Could not create or find event");
return;
}
console.log(`✅ Using event: ${event.id} (${event.name})`);
// Get all police units to assign patrol units to
const policeUnits = await this.prisma.units.findMany({
select: {
code_unit: true,
name: true,
type: true,
district_id: true, // Include district_id directly
},
});
if (!policeUnits.length) {
console.error('❌ No police units found. Please seed units first.');
return;
}
// Patrol unit types with proper weighting
const patrolTypes = ['car', 'motorcycle', 'foot', 'mixed', 'drone'];
const weightedPatrolTypes = {
'polres': { car: 40, motorcycle: 30, foot: 10, mixed: 15, drone: 5 },
'polsek': { car: 30, motorcycle: 40, foot: 20, mixed: 10, drone: 0 },
'default': { car: 35, motorcycle: 35, foot: 15, mixed: 10, drone: 5 }
};
// Status options with proper weighting
const statusOptions = ['active', 'standby', 'maintenance', 'patrol', 'on duty', 'off duty'];
const weightedStatus = {
'active': 30,
'standby': 25,
'maintenance': 5,
'patrol': 20,
'on duty': 15,
'off duty': 5
};
// Define patrol radius ranges based on type
const getPatrolRadius = (type: string): number => {
switch (type) {
case 'car':
return parseFloat(faker.number.float({ min: 5000, max: 8000, fractionDigits: 2 }).toFixed(2));
case 'motorcycle':
return parseFloat(faker.number.float({ min: 3000, max: 5000, fractionDigits: 2 }).toFixed(2));
case 'foot':
return parseFloat(faker.number.float({ min: 500, max: 1500, fractionDigits: 2 }).toFixed(2));
case 'drone':
return parseFloat(faker.number.float({ min: 2000, max: 4000, fractionDigits: 2 }).toFixed(2));
case 'mixed':
default:
return parseFloat(faker.number.float({ min: 2000, max: 6000, fractionDigits: 2 }).toFixed(2));
}
};
// Mapping type to code
const typeCodeMap: Record<string, string> = {
car: "C",
motorcycle: "M",
foot: "F",
mixed: "X",
drone: "D",
};
// Get locations for each district to assign to patrol units
const locationsByDistrict = await this.getLocationsByDistrict();
// Generate patrol units for each police unit
const patrolUnits = [];
for (const unit of policeUnits) {
// Number of patrol units per police unit varies by type
const patrolCount = unit.type === 'polres' ?
faker.number.int({ min: 5, max: 8 }) :
faker.number.int({ min: 2, max: 5 });
const unitTypeWeights = weightedPatrolTypes[unit.type as keyof typeof weightedPatrolTypes] ||
weightedPatrolTypes.default;
for (let i = 1; i <= patrolCount; i++) {
// Select patrol type based on weighted distribution
const patrolType = this.getWeightedRandomItem(unitTypeWeights) as string;
const patrolName = `${unit.name.replace('Polsek', 'Patroli').replace('Polres', 'Patroli')} ${patrolType.charAt(0).toUpperCase() + patrolType.slice(1)
} ${i}`;
const radius = getPatrolRadius(patrolType);
const status = this.getWeightedRandomItem(weightedStatus) as string;
const districtId = unit.district_id;
if (!districtId) {
console.log(`⚠️ No district_id for unit ${unit.name}, skipping patrol unit`);
continue;
}
// Get or create a location for this patrol unit
const locationId = await this.getOrCreateLocation(districtId, locationsByDistrict, event.id);
if (!locationId) {
console.log(`⚠️ Could not get/create location for patrol unit in ${unit.name}, skipping`);
continue;
}
const typeCode = typeCodeMap[patrolType] || "P";
const codeUnitLast2 = unit.code_unit.slice(-2);
try {
const newId = await generateIdWithDbCounter(
"patrol_units",
{
prefix: "PU",
segments: {
codes: [typeCode + codeUnitLast2],
sequentialDigits: 2,
},
format: "{prefix}-{codes}{sequence}",
},
CRegex.PATROL_UNIT_ID_REGEX
);
patrolUnits.push({
id: newId,
unit_id: unit.code_unit,
location_id: locationId,
name: patrolName,
type: patrolType,
status: status,
radius: radius,
});
} catch (error) {
console.error(`Error generating ID for patrol unit: ${error}`);
}
}
}
// Insert patrol units in smaller batches
if (patrolUnits.length > 0) {
await this.insertPatrolUnitsInBatches(patrolUnits);
console.log(`🚓 Created ${patrolUnits.length} patrol units for ${policeUnits.length} police units`);
} else {
console.warn('⚠️ No patrol unit data to insert');
}
}
// Helper: Ensure we have a user, event and session
private async ensureEventAndSession(): Promise<any> {
// Find or create a user
let user = await this.prisma.users.findFirst({
where: {
email: "sigapcompany@gmail.com"
}
});
if (!user) {
// Get the system admin role
const adminRole = await this.prisma.roles.findFirst({
where: {
name: "admin"
}
});
if (!adminRole) {
console.error("❌ Admin role not found. Please seed roles first.");
return null;
}
// Create a user if none exists
try {
user = await this.prisma.users.create({
data: {
email: "sigapcompany@gmail.com",
roles_id: adminRole.id,
is_anonymous: false,
email_confirmed_at: new Date(),
confirmed_at: new Date()
}
});
console.log("✅ Created user for patrol units");
} catch (error) {
console.error("❌ Error creating user:", error);
return null;
}
}
// Find or create an event
let event = await this.prisma.events.findFirst({
where: {
user_id: user.id
}
});
if (!event) {
try {
event = await this.prisma.events.create({
data: {
name: "Patrol Operations",
description: "System-generated event for patrol units",
user_id: user.id
}
});
console.log("✅ Created event for patrol units");
// Create a session for this event
const session = await this.prisma.sessions.create({
data: {
user_id: user.id,
event_id: event.id,
status: "active"
}
});
console.log("✅ Created session for patrol units");
} catch (error) {
console.error("❌ Error creating event or session:", error);
return null;
}
}
return event;
}
// Helper: Get locations organized by district
private async getLocationsByDistrict(): Promise<Record<string, any[]>> {
const locationsData = await this.prisma.locations.findMany({
select: {
id: true,
district_id: true,
latitude: true,
longitude: true,
},
take: 500 // Limit the number of locations to query
});
return locationsData.reduce((acc, location) => {
if (!acc[location.district_id]) {
acc[location.district_id] = [];
}
acc[location.district_id].push(location);
return acc;
}, {} as Record<string, any[]>);
}
// Helper: Get a random coordinate from district GeoJSON
private getRandomDistrictCoordinate(districtId: string): { latitude: number, longitude: number } | null {
console.log(`Trying to find coordinates for district ID: ${districtId}`);
// Check if districtsGeoJson is properly loaded
if (!districtsGeoJson || !districtsGeoJson.features || !Array.isArray(districtsGeoJson.features)) {
console.error("GeoJSON data is missing or malformed:", districtsGeoJson);
return null;
}
// Try to find the district feature using multiple property checks
const feature = districtsGeoJson.features.find(f => {
if (!f.properties) return false;
// Try different property names that might contain the district ID
return (
f.properties.kode_kec === districtId
);
});
if (!feature) {
console.error(`No matching district found for ID: ${districtId}`);
console.log("Available district properties:", districtsGeoJson.features.slice(0, 2).map(f => f.properties));
return null;
}
if (!feature.geometry) {
console.error(`District found but has no geometry: ${districtId}`);
return null;
}
console.log(`Found district: ${feature.properties?.kecamatan || 'Unknown'}`);
// Extract coordinates based on geometry type
let allCoords: number[][] = [];
if (feature.geometry.type === "Polygon") {
// For Polygon, get all coordinate points from all rings
allCoords = feature.geometry.coordinates.flat(2);
}
else if (feature.geometry.type === "MultiPolygon") {
// For MultiPolygon, flatten to get all points from all polygons
// MultiPolygon structure: [[[[x,y,z], [x,y,z]]], [[[x,y,z], [x,y,z]]]]
allCoords = feature.geometry.coordinates.flat(2);
}
// Filter out any invalid coordinates and handle 3D coordinates (x,y,z)
const validCoords = allCoords.filter(coord =>
Array.isArray(coord) &&
coord.length >= 2 &&
typeof coord[0] === 'number' &&
typeof coord[1] === 'number'
);
if (validCoords.length === 0) {
console.error(`No valid coordinates found in the geometry for district: ${districtId}`);
console.log("Geometry structure:", JSON.stringify(feature.geometry, null, 2));
return null;
}
// Get a random coordinate pair, handling 3D coordinates if present
const randomCoord = validCoords[Math.floor(Math.random() * validCoords.length)];
// Get longitude (x) and latitude (y) from the coordinate
const lng = randomCoord[0];
const lat = randomCoord[1];
console.log(`Generated coordinates: ${lat}, ${lng} for district ${districtId}`);
return { latitude: lat, longitude: lng };
}
// Helper: Get or create a location for the patrol unit
private async getOrCreateLocation(
districtId: string,
locationsByDistrict: Record<string, any[]>,
eventId?: string
): Promise<string | undefined> {
// Try to use an existing location for this district
const districtLocations = locationsByDistrict[districtId] || [];
if (districtLocations.length > 0) {
const randomLocation = faker.helpers.arrayElement(districtLocations);
return randomLocation.id;
}
// Find the event to use
if (!eventId) {
const event = await this.prisma.events.findFirst();
if (!event) {
console.error("❌ No event found. Cannot create location.");
return undefined;
}
eventId = event.id;
}
// Generate a new location using districtGeoJson if no existing locations
const coord = this.getRandomDistrictCoordinate(districtId);
if (!coord) {
console.warn(`Could not generate coordinates from GeoJSON for district ${districtId}, using fallback...`);
return undefined;
}
// Create location in both databases for consistency
try {
const newLocation = {
district_id: districtId,
event_id: eventId,
address: `Generated Patrol Location, District ${districtId}`,
type: "patrol",
latitude: coord.latitude,
longitude: coord.longitude,
land_area: null,
location: `POINT(${coord.longitude} ${coord.latitude})`,
};
// Insert to both databases for consistency
const { data, error } = await this.supabase
.from("locations")
.insert([newLocation])
.select('id')
.single();
if (error) {
console.error("Failed to insert location to Supabase:", error);
return undefined;
}
// Update our local cache
if (!locationsByDistrict[districtId]) {
locationsByDistrict[districtId] = [];
}
locationsByDistrict[districtId].push({ ...newLocation, id: data.id });
return data.id;
} catch (err) {
console.error("Failed to create location:", err);
return undefined;
}
}
// Helper: Insert patrol units in batches with better error handling
private async insertPatrolUnitsInBatches(patrolUnits: any[]): Promise<void> {
const batchSize = 50; // Smaller batch size for better reliability
for (let i = 0; i < patrolUnits.length; i += batchSize) {
const batch = patrolUnits.slice(i, i + batchSize);
try {
// Insert to Supabase
const { error } = await this.supabase
.from('patrol_units')
.insert(batch);
if (error) {
console.error(`Error inserting patrol units batch ${Math.floor(i / batchSize) + 1}:`, error);
}
// Small delay between batches to avoid rate limiting
await new Promise(resolve => setTimeout(resolve, 500));
} catch (err) {
console.error(`Exception when inserting patrol units batch ${Math.floor(i / batchSize) + 1}:`, err);
}
}
}
// Helper: Get a weighted random item from a weighted object
private getWeightedRandomItem(weightedItems: Record<string, number>): string | number {
const entries = Object.entries(weightedItems);
const weights = entries.map(([_, weight]) => weight);
const totalWeight = weights.reduce((sum, weight) => sum + weight, 0);
let random = Math.random() * totalWeight;
for (const [item, weight] of entries) {
random -= weight;
if (random < 0) {
return item;
}
}
// Fallback to first item if something goes wrong
return entries[0][0];
}
}

View File

@ -12,18 +12,25 @@ export class PermissionSeeder {
try { try {
// Fetch all resources and roles // Fetch all resources and roles
const allResources = await this.prisma.resources.findMany(); const allResources = await this.prisma.resources.findMany();
const adminRole = await this.prisma.roles.findUnique({ const adminRole = await this.prisma.roles.findUnique({
where: { name: 'admin' }, where: { name: 'admin' },
}); });
const officerRole = await this.prisma.roles.findUnique({
where: { name: 'officer' },
})
const viewerRole = await this.prisma.roles.findUnique({ const viewerRole = await this.prisma.roles.findUnique({
where: { name: 'viewer' }, where: { name: 'viewer' },
}); });
const staffRole = await this.prisma.roles.findUnique({ const staffRole = await this.prisma.roles.findUnique({
where: { name: 'staff' }, where: { name: 'staff' },
}); });
if (!adminRole || !viewerRole || !staffRole) { if (!adminRole || !viewerRole || !staffRole || !officerRole) {
console.error('Roles not found. Please seed roles first.'); console.error('One or more roles not found. Please seed roles first.');
return; return;
} }
@ -59,6 +66,62 @@ export class PermissionSeeder {
} }
} }
// Officer permissions - operational access focused on their domain
for (const resource of allResources) {
// Define which resources officers can fully manage
const officerFullAccessResources = [
'incident_logs',
'evidence',
'locations',
'location_logs',
'patrol_units'
];
// Define resources officers can view and update but not create/delete
const officerLimitedAccessResources = [
'crime_incidents',
'officers'
];
// Define resources officers can only read
const officerReadOnlyResources = [
'crimes',
'crime_categories',
'units',
'districts',
'cities'
];
if (officerFullAccessResources.includes(resource.name)) {
// Officers can fully manage operational resources
await this.createPermissions(officerRole.id, resource.id, [
'create',
'read',
'update',
'delete',
]);
} else if (officerLimitedAccessResources.includes(resource.name)) {
// Officers can read and update but not create/delete certain resources
await this.createPermissions(officerRole.id, resource.id, [
'read',
'update',
]);
} else if (officerReadOnlyResources.includes(resource.name)) {
// Officers can only read reference data
await this.createPermissions(officerRole.id, resource.id, ['read']);
} else if (['events', 'sessions'].includes(resource.name)) {
// Officers can create and manage events/sessions
await this.createPermissions(officerRole.id, resource.id, [
'create',
'read',
'update',
]);
} else {
// For all other resources, officers get read-only access
await this.createPermissions(officerRole.id, resource.id, ['read']);
}
}
console.log('Permissions seeded successfully!'); console.log('Permissions seeded successfully!');
} catch (error) { } catch (error) {
console.error('Error seeding permissions:', error); console.error('Error seeding permissions:', error);

View File

@ -0,0 +1,423 @@
-- Updated function to handle conditional user creation based on metadata
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
AS $$
DECLARE
role_id UUID;
officer_role_id UUID;
is_officer BOOLEAN;
officer_data JSONB;
unit_id VARCHAR;
BEGIN
-- Check if the user is registering as an officer
is_officer := FALSE;
-- Check user_metadata for officer flag
IF NEW.raw_user_meta_data ? 'is_officer' THEN
is_officer := (NEW.raw_user_meta_data->>'is_officer')::boolean;
END IF;
IF is_officer THEN
-- Get officer role ID
SELECT id INTO officer_role_id FROM public.roles WHERE name = 'officer' LIMIT 1;
IF officer_role_id IS NULL THEN
RAISE EXCEPTION 'Officer role not found';
END IF;
-- Extract officer data from metadata
officer_data := NEW.raw_user_meta_data->'officer_data';
-- Get unit ID from metadata
unit_id := officer_data->>'unit_id';
IF unit_id IS NULL THEN
RAISE EXCEPTION 'Unit ID is required for officer registration';
END IF;
-- Insert into officers table
INSERT INTO public.officers (
id,
unit_id,
role_id,
nrp,
name,
rank,
position,
phone,
email,
created_at,
updated_at
) VALUES (
NEW.id,
unit_id,
officer_role_id,
officer_data->>'nrp',
COALESCE(officer_data->>'name', NEW.email),
officer_data->>'rank',
officer_data->>'position',
COALESCE(NEW.phone, officer_data->>'phone'),
NEW.email,
NEW.created_at,
NEW.updated_at
);
-- Return early since we've handled the officer case
RETURN NEW;
ELSE
-- Standard user registration - Get viewer role ID
SELECT id INTO role_id FROM public.roles WHERE name = 'viewer' LIMIT 1;
IF role_id IS NULL THEN
RAISE EXCEPTION 'Viewer role not found';
END IF;
-- Insert into users table
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 profiles table
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 IF;
END;
$$;
-- Create or replace trigger for user creation
DROP TRIGGER IF EXISTS "on_auth_user_created" ON "auth"."users";
CREATE TRIGGER "on_auth_user_created"
AFTER INSERT ON "auth"."users"
FOR EACH ROW
EXECUTE FUNCTION public.handle_new_user();
-- Updated function to handle conditional user update based on metadata
CREATE OR REPLACE FUNCTION public.handle_user_update()
RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
AS $$
DECLARE
is_officer BOOLEAN;
officer_data JSONB;
BEGIN
-- Check if the user is an officer
is_officer := EXISTS (SELECT 1 FROM public.officers WHERE id = NEW.id);
-- Also check if user_metadata indicates officer status (for cases where metadata was updated)
IF NOT is_officer AND NEW.raw_user_meta_data ? 'is_officer' THEN
is_officer := (NEW.raw_user_meta_data->>'is_officer')::boolean;
END IF;
IF is_officer THEN
-- Extract officer data from metadata if it exists
IF NEW.raw_user_meta_data ? 'officer_data' THEN
officer_data := NEW.raw_user_meta_data->'officer_data';
-- Update officer record
UPDATE public.officers
SET
nrp = COALESCE(officer_data->>'nrp', nrp),
name = COALESCE(officer_data->>'name', name),
rank = COALESCE(officer_data->>'rank', rank),
position = COALESCE(officer_data->>'position', position),
phone = COALESCE(NEW.phone, officer_data->>'phone', phone),
email = COALESCE(NEW.email, email),
updated_at = NOW()
WHERE id = NEW.id;
ELSE
-- Basic update with available auth data
UPDATE public.officers
SET
phone = COALESCE(NEW.phone, phone),
email = COALESCE(NEW.email, email),
updated_at = NOW()
WHERE id = NEW.id;
END IF;
ELSE
-- Standard user update
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;
-- Create profile if it doesn't exist
INSERT INTO public.profiles (id, user_id, username)
SELECT gen_random_uuid(), NEW.id, public.generate_username(NEW.email)
WHERE NOT EXISTS (
SELECT 1 FROM public.profiles WHERE user_id = NEW.id
)
ON CONFLICT (user_id) DO NOTHING;
END IF;
RETURN NEW;
END;
$$;
-- Create or replace trigger for user updates
DROP TRIGGER IF EXISTS "on_auth_user_updated" ON "auth"."users";
CREATE TRIGGER "on_auth_user_updated"
AFTER UPDATE ON "auth"."users"
FOR EACH ROW
WHEN (OLD.* IS DISTINCT FROM NEW.*)
EXECUTE FUNCTION public.handle_user_update();
-- Updated function to handle conditional user deletion based on role
CREATE OR REPLACE FUNCTION public.handle_user_delete()
RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
AS $$
DECLARE
is_officer BOOLEAN;
BEGIN
-- Check if the user is an officer
is_officer := EXISTS (SELECT 1 FROM public.officers WHERE id = OLD.id);
IF is_officer THEN
-- Delete officer record
DELETE FROM public.officers WHERE id = OLD.id;
ELSE
-- Delete standard user data
DELETE FROM public.profiles WHERE user_id = OLD.id;
DELETE FROM public.users WHERE id = OLD.id;
END IF;
RETURN OLD;
END;
$$;
-- Create or replace trigger for user deletion
DROP TRIGGER IF EXISTS "on_auth_user_deleted" ON "auth"."users";
CREATE TRIGGER "on_auth_user_deleted"
AFTER DELETE ON "auth"."users"
FOR EACH ROW
EXECUTE FUNCTION public.handle_user_delete();
-- Function to handle when a user is converted to/from an officer
CREATE OR REPLACE FUNCTION public.handle_user_type_change()
RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
AS $$
DECLARE
is_officer_before BOOLEAN;
is_officer_after BOOLEAN;
officer_role_id UUID;
viewer_role_id UUID;
officer_data JSONB;
unit_id VARCHAR;
BEGIN
-- Determine officer status before and after update
is_officer_before := EXISTS (SELECT 1 FROM public.officers WHERE id = NEW.id);
-- Check if user_metadata indicates officer status after update
is_officer_after := FALSE;
IF NEW.raw_user_meta_data ? 'is_officer' THEN
is_officer_after := (NEW.raw_user_meta_data->>'is_officer')::boolean;
END IF;
-- If status changed from regular user to officer
IF NOT is_officer_before AND is_officer_after THEN
-- Get officer role ID
SELECT id INTO officer_role_id FROM public.roles WHERE name = 'officer' LIMIT 1;
IF officer_role_id IS NULL THEN
RAISE EXCEPTION 'Officer role not found';
END IF;
-- Extract officer data from metadata
officer_data := NEW.raw_user_meta_data->'officer_data';
-- Get unit ID from metadata
unit_id := officer_data->>'unit_id';
IF unit_id IS NULL THEN
RAISE EXCEPTION 'Unit ID is required for officer registration';
END IF;
-- Insert into officers table
INSERT INTO public.officers (
id,
unit_id,
role_id,
nrp,
name,
rank,
position,
phone,
email,
created_at,
updated_at
) VALUES (
NEW.id,
unit_id,
officer_role_id,
officer_data->>'nrp',
COALESCE(officer_data->>'name', NEW.email),
officer_data->>'rank',
officer_data->>'position',
COALESCE(NEW.phone, officer_data->>'phone'),
NEW.email,
NEW.created_at,
NEW.updated_at
);
-- Delete regular user data
DELETE FROM public.profiles WHERE user_id = NEW.id;
DELETE FROM public.users WHERE id = NEW.id;
-- If status changed from officer to regular user
ELSIF is_officer_before AND NOT is_officer_after THEN
-- Get viewer role ID
SELECT id INTO viewer_role_id FROM public.roles WHERE name = 'viewer' LIMIT 1;
IF viewer_role_id IS NULL THEN
RAISE EXCEPTION 'Viewer role not found';
END IF;
-- Insert into users table
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,
viewer_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 profiles table
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
);
-- Delete officer record
DELETE FROM public.officers WHERE id = NEW.id;
END IF;
RETURN NEW;
END;
$$;
-- Create or replace trigger for user type changes
DROP TRIGGER IF EXISTS "on_auth_user_type_change" ON "auth"."users";
CREATE TRIGGER "on_auth_user_type_change"
AFTER UPDATE ON "auth"."users"
FOR EACH ROW
WHEN (
(OLD.raw_user_meta_data->>'is_officer')::boolean IS DISTINCT FROM
(NEW.raw_user_meta_data->>'is_officer')::boolean
)
EXECUTE FUNCTION public.handle_user_type_change();
-- Add an informational message about trigger creation
DO $$
BEGIN
RAISE NOTICE 'All authentication triggers have been created successfully';
END $$;

View File

@ -0,0 +1,101 @@
create or replace function public.nearby_units(
lat double precision,
lon double precision,
max_results integer default 5
)
returns table (
code_unit varchar,
name text,
type text,
address text,
district_id varchar,
lat_unit double precision,
lon_unit double precision,
distance_km double precision
)
language sql
as $$
select
u.code_unit,
u.name,
u.type,
u.address,
u.district_id,
gis.ST_Y(u.location::gis.geometry) as lat_unit,
gis.ST_X(u.location::gis.geometry) as lon_unit,
gis.ST_Distance(
u.location::gis.geography,
gis.ST_SetSRID(gis.ST_MakePoint(lon, lat), 4326)::gis.geography
) / 1000 as distance_km
from units u
order by gis.ST_Distance(
u.location::gis.geography,
gis.ST_SetSRID(gis.ST_MakePoint(lon, lat), 4326)::gis.geography
)
limit max_results
$$;
CREATE OR REPLACE FUNCTION public.update_location_distance_to_unit()
RETURNS TRIGGER AS $$
DECLARE
loc_lat FLOAT;
loc_lng FLOAT;
unit_lat FLOAT;
unit_lng FLOAT;
loc_point GEOGRAPHY;
unit_point GEOGRAPHY;
BEGIN
-- Ambil lat/lng dari location yang baru
SELECT gis.ST_Y(NEW.location::gis.geometry), gis.ST_X(NEW.location::gis.geometry)
INTO loc_lat, loc_lng;
-- Ambil lat/lng dari unit di distrik yang sama
SELECT gis.ST_Y(u.location::gis.geometry), gis.ST_X(u.location::gis.geometry)
INTO unit_lat, unit_lng
FROM units u
WHERE u.district_id = NEW.district_id
LIMIT 1;
-- Jika tidak ada unit di distrik yang sama, kembalikan NEW tanpa perubahan
IF unit_lat IS NULL OR unit_lng IS NULL THEN
RETURN NEW;
END IF;
-- Buat point geography dari lat/lng
loc_point := gis.ST_SetSRID(gis.ST_MakePoint(loc_lng, loc_lat), 4326)::gis.geography;
unit_point := gis.ST_SetSRID(gis.ST_MakePoint(unit_lng, unit_lat), 4326)::gis.geography;
-- Update jaraknya ke kolom distance_to_unit
NEW.distance_to_unit := gis.ST_Distance(loc_point, unit_point) / 1000;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create the trigger
CREATE OR REPLACE TRIGGER update_location_distance_trigger
BEFORE INSERT OR UPDATE OF location, district_id
ON locations
FOR EACH ROW
EXECUTE FUNCTION public.update_location_distance_to_unit();
-- Spatial index untuk tabel units
CREATE INDEX IF NOT EXISTS idx_units_location_gist ON units USING GIST (location);
-- Spatial index untuk tabel locations
CREATE INDEX IF NOT EXISTS idx_locations_location_gist ON locations USING GIST (location);
-- Index untuk mempercepat pencarian units berdasarkan district_id
CREATE INDEX IF NOT EXISTS idx_units_district_id ON units (district_id);
-- Index untuk mempercepat pencarian locations berdasarkan district_id
CREATE INDEX IF NOT EXISTS idx_locations_district_id ON locations (district_id);
-- Index untuk kombinasi location dan district_id pada tabel units
CREATE INDEX IF NOT EXISTS idx_units_location_district ON units (district_id, location);
-- Analisis tabel setelah membuat index
ANALYZE units;
ANALYZE locations;