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:
parent
223195e3fb
commit
4b0bae1bcd
|
@ -6,9 +6,8 @@ import {
|
|||
AuthenticationError,
|
||||
UnauthenticatedError,
|
||||
} from '@/src/entities/errors/auth';
|
||||
import { InputParseError, NotFoundError } from '@/src/entities/errors/common';
|
||||
import { districtsGeoJson } from '@/prisma/data/geojson/jember/districts';
|
||||
import { calculateCentroid } from '@/app/_lib/transformGeoJSON';
|
||||
import { NotFoundError } from '@/src/entities/errors/common';
|
||||
|
||||
|
||||
/**
|
||||
* Initialize district data in the database from GeoJSON
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
"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 {
|
||||
BASE_BEARING,
|
||||
|
@ -14,6 +14,7 @@ import IncidentPopup from "../pop-up/incident-popup";
|
|||
import type mapboxgl from "mapbox-gl";
|
||||
import type { MapGeoJSONFeature, MapMouseEvent } from "react-map-gl/mapbox";
|
||||
import { manageLayerVisibility } from "@/app/_utils/map/layer-visibility";
|
||||
import { getCategoryColor } from "@/app/_utils/colors";
|
||||
|
||||
interface IAllIncidentsLayerProps {
|
||||
visible?: boolean;
|
||||
|
@ -155,6 +156,22 @@ export default function AllIncidentsLayer(
|
|||
useEffect(() => {
|
||||
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
|
||||
const allIncidents = crimes.flatMap((crime) => {
|
||||
return crime.crime_incidents
|
||||
|
@ -240,20 +257,12 @@ export default function AllIncidentsLayer(
|
|||
"circle-color": [
|
||||
"match",
|
||||
["get", "category"],
|
||||
"Theft",
|
||||
"#FF5733",
|
||||
"Assault",
|
||||
"#C70039",
|
||||
"Robbery",
|
||||
"#900C3F",
|
||||
"Burglary",
|
||||
"#581845",
|
||||
"Fraud",
|
||||
"#FFC300",
|
||||
"Homicide",
|
||||
"#FF0000",
|
||||
// Use dynamic mapping from pre-computed category colors
|
||||
...Object.entries(categoryColorMap).flatMap(
|
||||
([category, color]) => [category, color]
|
||||
),
|
||||
// Default color for other categories
|
||||
"#2874A6",
|
||||
getCategoryColor("Unknown")
|
||||
],
|
||||
"circle-opacity": 0.4,
|
||||
"circle-blur": 0.6,
|
||||
|
@ -278,20 +287,12 @@ export default function AllIncidentsLayer(
|
|||
"circle-color": [
|
||||
"match",
|
||||
["get", "category"],
|
||||
"Theft",
|
||||
"#FF5733",
|
||||
"Assault",
|
||||
"#C70039",
|
||||
"Robbery",
|
||||
"#900C3F",
|
||||
"Burglary",
|
||||
"#581845",
|
||||
"Fraud",
|
||||
"#FFC300",
|
||||
"Homicide",
|
||||
"#FF0000",
|
||||
// Use dynamic mapping from pre-computed category colors
|
||||
...Object.entries(categoryColorMap).flatMap(
|
||||
([category, color]) => [category, color]
|
||||
),
|
||||
// Default color for other categories
|
||||
"#2874A6",
|
||||
getCategoryColor("Unknown")
|
||||
],
|
||||
"circle-stroke-width": 1,
|
||||
"circle-stroke-color": "#FFFFFF",
|
||||
|
@ -317,20 +318,12 @@ export default function AllIncidentsLayer(
|
|||
"circle-color": [
|
||||
"match",
|
||||
["get", "category"],
|
||||
"Theft",
|
||||
"#FF5733",
|
||||
"Assault",
|
||||
"#C70039",
|
||||
"Robbery",
|
||||
"#900C3F",
|
||||
"Burglary",
|
||||
"#581845",
|
||||
"Fraud",
|
||||
"#FFC300",
|
||||
"Homicide",
|
||||
"#FF0000",
|
||||
// Use dynamic mapping from pre-computed category colors
|
||||
...Object.entries(categoryColorMap).flatMap(
|
||||
([category, color]) => [category, color]
|
||||
),
|
||||
// Default color for other categories
|
||||
"#2874A6",
|
||||
getCategoryColor("Unknown")
|
||||
],
|
||||
"circle-stroke-width": 1,
|
||||
"circle-stroke-color": "#FFFFFF",
|
||||
|
|
|
@ -467,15 +467,13 @@ export default function Layers({
|
|||
[],
|
||||
);
|
||||
|
||||
const showHeatmapLayer = activeControl === "heatmap" &&
|
||||
sourceType !== "cbu";
|
||||
const showHeatmapLayer = activeControl === "heatmap" && sourceType !== "cbu";
|
||||
const showUnitsLayer = activeControl === "units";
|
||||
const showTimelineLayer = activeControl === "timeline";
|
||||
const showRecentIncidents = activeControl === "recents";
|
||||
const showAllIncidents = activeControl === "incidents"; // Use the "incidents" tooltip for all incidents
|
||||
const showDistrictFill = activeControl === "incidents" ||
|
||||
activeControl === "clusters" ||
|
||||
activeControl === "recents";
|
||||
const showDistrictFill = activeControl === "clusters";
|
||||
|
||||
const showIncidentMarkers = activeControl !== "heatmap" &&
|
||||
activeControl !== "timeline" && sourceType !== "cbu";
|
||||
|
||||
|
@ -504,7 +502,7 @@ export default function Layers({
|
|||
"crime-points",
|
||||
"crime-count-labels",
|
||||
];
|
||||
const unclusteredLayerIds = ["unclustered-point"];
|
||||
|
||||
const allIncidentsLayerIds = [
|
||||
"all-incidents-pulse",
|
||||
"all-incidents-circles",
|
||||
|
@ -535,9 +533,7 @@ export default function Layers({
|
|||
manageLayerVisibility(mapboxMap, allIncidentsLayerIds, false);
|
||||
}
|
||||
|
||||
if (activeControl !== "incidents" && activeControl !== "recents") {
|
||||
manageLayerVisibility(mapboxMap, unclusteredLayerIds, false);
|
||||
}
|
||||
|
||||
}, [activeControl, mapboxMap]);
|
||||
|
||||
return (
|
||||
|
|
|
@ -10,7 +10,8 @@ import db from '../../prisma/db';
|
|||
import { v4 as uuidv4 } from 'uuid';
|
||||
import * as crypto from 'crypto';
|
||||
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
|
||||
const usedIdRegistry = new Set<string>();
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
export class CRegex {
|
||||
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 CR_YEAR_SEQUENCE = /(\d{4})(?=-\d{4}$)/;
|
||||
static readonly CR_SEQUENCE_END = /(\d{4})$/;
|
||||
static readonly FORMAT_ID_YEAR_SEQUENCE = /(\d{4})(?=-\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})$/;
|
||||
}
|
||||
|
|
|
@ -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.
|
|
@ -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 $$;
|
|
@ -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 }
|
||||
]
|
||||
}
|
||||
];
|
|
@ -89,5 +89,82 @@ export const resourcesData = [
|
|||
attributes: {
|
||||
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']
|
||||
}
|
||||
}
|
||||
];
|
||||
|
|
|
@ -3,6 +3,10 @@ export const rolesData = [
|
|||
name: 'admin',
|
||||
description: 'Administrator with full access to all features.',
|
||||
},
|
||||
{
|
||||
name: 'officer',
|
||||
description: 'Police officer with access to patrol and report features.',
|
||||
},
|
||||
{
|
||||
name: 'viewer',
|
||||
description: 'Read-only access to the data.',
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -0,0 +1,4 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "evidence" ADD COLUMN "caption" VARCHAR(255),
|
||||
ADD COLUMN "description" VARCHAR(255),
|
||||
ADD COLUMN "metadata" JSONB;
|
|
@ -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;
|
|
@ -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;
|
|
@ -63,6 +63,7 @@ model roles {
|
|||
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
permissions permissions[]
|
||||
users users[]
|
||||
officers officers[]
|
||||
}
|
||||
|
||||
model sessions {
|
||||
|
@ -173,12 +174,12 @@ model crimes {
|
|||
method String? @db.VarChar(100)
|
||||
month Int?
|
||||
number_of_crime Int @default(0)
|
||||
crime_cleared Int @default(0)
|
||||
avg_crime Float @default(0)
|
||||
score Float @default(0)
|
||||
updated_at DateTime? @default(now()) @db.Timestamptz(6)
|
||||
year Int?
|
||||
source_type String? @db.VarChar(100)
|
||||
crime_cleared Int @default(0)
|
||||
avg_crime Float @default(0)
|
||||
crime_incidents crime_incidents[]
|
||||
districts districts @relation(fields: [district_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
|
||||
|
@ -230,20 +231,22 @@ model locations {
|
|||
latitude Float
|
||||
longitude Float
|
||||
land_area Float?
|
||||
distance_to_unit Float?
|
||||
polygon Unsupported("geometry")?
|
||||
geometry Unsupported("geometry")?
|
||||
created_at DateTime? @default(now()) @db.Timestamptz(6)
|
||||
updated_at DateTime? @default(now()) @db.Timestamptz(6)
|
||||
location Unsupported("geography")
|
||||
distance_to_unit Float?
|
||||
crime_incidents crime_incidents[]
|
||||
incident_logs incident_logs[]
|
||||
districts districts @relation(fields: [district_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
event events @relation(fields: [event_id], references: [id])
|
||||
patrol_units patrol_units[]
|
||||
|
||||
@@index([district_id], map: "idx_locations_district_id")
|
||||
@@index([type], map: "idx_locations_type")
|
||||
@@index([location], map: "idx_locations_geography", type: Gist)
|
||||
@@index([location], map: "idx_locations_location_gist", type: Gist)
|
||||
}
|
||||
|
||||
model incident_logs {
|
||||
|
@ -260,40 +263,110 @@ model incident_logs {
|
|||
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)
|
||||
user users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
evidence evidence[]
|
||||
|
||||
@@index([category_id], map: "idx_incident_logs_category_id")
|
||||
@@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 {
|
||||
code_unit String @id @unique @db.VarChar(20)
|
||||
district_id String? @unique @db.VarChar(20)
|
||||
city_id String @db.VarChar(20)
|
||||
name String @db.VarChar(100)
|
||||
description String?
|
||||
type unit_type
|
||||
created_at DateTime? @default(now()) @db.Timestamptz(6)
|
||||
updated_at DateTime? @default(now()) @db.Timestamptz(6)
|
||||
address String?
|
||||
phone String?
|
||||
land_area Float?
|
||||
latitude Float
|
||||
longitude Float
|
||||
location Unsupported("geography")
|
||||
city_id String @db.VarChar(20)
|
||||
phone String?
|
||||
unit_statistics unit_statistics[]
|
||||
districts districts? @relation(fields: [district_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
cities cities @relation(fields: [city_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
districts districts? @relation(fields: [district_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
officers officers[]
|
||||
patrol_units patrol_units[]
|
||||
|
||||
@@index([name], map: "idx_units_name")
|
||||
@@index([type], map: "idx_units_type")
|
||||
@@index([code_unit], map: "idx_units_code_unit")
|
||||
@@index([district_id], map: "idx_units_district_id")
|
||||
@@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 {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
code_unit String @db.VarChar(20)
|
||||
crime_total Int
|
||||
crime_cleared Int
|
||||
percentage Float?
|
||||
|
@ -302,6 +375,7 @@ model unit_statistics {
|
|||
year Int
|
||||
created_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)
|
||||
|
||||
@@unique([code_unit, month, year])
|
||||
|
|
|
@ -9,6 +9,8 @@ import { DemographicsSeeder } from './seeds/demographic';
|
|||
import { CrimeCategoriesSeeder } from './seeds/crime-category';
|
||||
|
||||
import { UnitSeeder } from './seeds/units';
|
||||
import { PatrolUnitsSeeder } from './seeds/patrol-units';
|
||||
import { OfficersSeeder } from './seeds/officers';
|
||||
import { CrimesSeeder } from './seeds/crimes';
|
||||
import { CrimeIncidentsByUnitSeeder } from './seeds/crime-incidents';
|
||||
import { CrimeIncidentsByTypeSeeder } from './seeds/crime-incidents-cbt';
|
||||
|
@ -31,16 +33,18 @@ class DatabaseSeeder {
|
|||
|
||||
// Daftar semua seeders di sini
|
||||
this.seeders = [
|
||||
new RoleSeeder(prisma),
|
||||
new ResourceSeeder(prisma),
|
||||
new PermissionSeeder(prisma),
|
||||
new CrimeCategoriesSeeder(prisma),
|
||||
new GeoJSONSeeder(prisma),
|
||||
new UnitSeeder(prisma),
|
||||
new DemographicsSeeder(prisma),
|
||||
new CrimesSeeder(prisma),
|
||||
// new RoleSeeder(prisma),
|
||||
// new ResourceSeeder(prisma),
|
||||
// new PermissionSeeder(prisma),
|
||||
// new CrimeCategoriesSeeder(prisma),
|
||||
// new GeoJSONSeeder(prisma),
|
||||
// new UnitSeeder(prisma),
|
||||
// new PatrolUnitsSeeder(prisma),
|
||||
// new OfficersSeeder(prisma),
|
||||
// new DemographicsSeeder(prisma),
|
||||
// new CrimesSeeder(prisma),
|
||||
// new CrimeIncidentsByUnitSeeder(prisma),
|
||||
new CrimeIncidentsByTypeSeeder(prisma),
|
||||
// new CrimeIncidentsByTypeSeeder(prisma),
|
||||
new IncidentLogSeeder(prisma),
|
||||
];
|
||||
}
|
||||
|
|
|
@ -13,6 +13,8 @@ import * as path from "path";
|
|||
import { CRegex } from "../../app/_utils/const/regex";
|
||||
|
||||
import { districtCenters } from "../data/jsons/district-center";
|
||||
import { districtsGeoJson } from "../data/geojson/jember/districts-geojson";
|
||||
|
||||
|
||||
type ICreateLocations = {
|
||||
id: string;
|
||||
|
@ -389,12 +391,10 @@ export class CrimeIncidentsByTypeSeeder {
|
|||
`${placeType} ${district.name}, ${streetName}, Jember`;
|
||||
break;
|
||||
case 2:
|
||||
randomAddress = `${streetName} Blok ${
|
||||
String.fromCharCode(
|
||||
randomAddress = `${streetName} Blok ${String.fromCharCode(
|
||||
65 + Math.floor(Math.random() * 26),
|
||||
)
|
||||
}-${
|
||||
Math.floor(Math.random() * 20) + 1
|
||||
}-${Math.floor(Math.random() * 20) + 1
|
||||
}, ${district.name}, Jember`;
|
||||
break;
|
||||
}
|
||||
|
@ -425,7 +425,7 @@ export class CrimeIncidentsByTypeSeeder {
|
|||
separator: "-",
|
||||
uniquenessStrategy: "counter",
|
||||
},
|
||||
CRegex.CR_YEAR_SEQUENCE,
|
||||
CRegex.FORMAT_ID_YEAR_SEQUENCE,
|
||||
);
|
||||
|
||||
const status = resolvedCount < crimesCleared
|
||||
|
@ -717,30 +717,129 @@ export class CrimeIncidentsByTypeSeeder {
|
|||
const points = [];
|
||||
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(
|
||||
(center) => center.kecamatan.toLowerCase() === districtNameLower,
|
||||
);
|
||||
|
||||
if (!districtCenter) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (districtCenter) {
|
||||
console.log(`Using district center fallback for: ${districtName}`);
|
||||
const centerLat = districtCenter.lat;
|
||||
const centerLng = districtCenter.lng;
|
||||
|
||||
// Skala kecil: radius hanya 0.5 km dari center (sekitar 500 meter)
|
||||
const radiusKm = 0.5;
|
||||
const estimatedRadiusKm = Math.sqrt(landArea / Math.PI) / 1000;
|
||||
const radiusKm = Math.min(3, Math.max(0.5, estimatedRadiusKm));
|
||||
const radiusDeg = radiusKm / 111;
|
||||
|
||||
for (let i = 0; i < numPoints; i++) {
|
||||
const angle = Math.random() * 2 * Math.PI;
|
||||
// Jarak random, lebih padat di tengah
|
||||
const distance = Math.pow(Math.random(), 1.5) * radiusDeg;
|
||||
|
||||
const latitude = centerLat + distance * Math.cos(angle);
|
||||
const longitude = centerLng +
|
||||
distance * Math.sin(angle) / Math.cos(centerLat * Math.PI / 180);
|
||||
|
||||
const pointRadius = distance * 111000;
|
||||
|
||||
points.push({
|
||||
|
@ -749,6 +848,9 @@ export class CrimeIncidentsByTypeSeeder {
|
|||
radius: pointRadius,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.error(`No data available for district: ${districtName}`);
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
|
|
|
@ -548,7 +548,7 @@ private generateDistributedPoints(
|
|||
separator: '-',
|
||||
uniquenessStrategy: 'counter',
|
||||
},
|
||||
CRegex.CR_YEAR_SEQUENCE
|
||||
CRegex.FORMAT_ID_YEAR_SEQUENCE
|
||||
);
|
||||
|
||||
// Determine status based on crime_cleared
|
||||
|
|
|
@ -227,7 +227,7 @@ export class CrimesSeeder {
|
|||
separator: "-",
|
||||
uniquenessStrategy: "counter",
|
||||
},
|
||||
CRegex.CR_YEAR_SEQUENCE,
|
||||
CRegex.FORMAT_ID_YEAR_SEQUENCE,
|
||||
);
|
||||
|
||||
crimesData.push({
|
||||
|
@ -311,7 +311,7 @@ export class CrimesSeeder {
|
|||
separator: "-",
|
||||
uniquenessStrategy: "counter",
|
||||
},
|
||||
CRegex.CR_YEAR_SEQUENCE,
|
||||
CRegex.FORMAT_ID_YEAR_SEQUENCE,
|
||||
);
|
||||
|
||||
crimesData.push({
|
||||
|
@ -395,7 +395,7 @@ export class CrimesSeeder {
|
|||
separator: "-",
|
||||
uniquenessStrategy: "counter",
|
||||
},
|
||||
CRegex.CR_SEQUENCE_END,
|
||||
CRegex.FORMAT_ID_SEQUENCE_END,
|
||||
);
|
||||
|
||||
crimesData.push({
|
||||
|
@ -473,7 +473,7 @@ export class CrimesSeeder {
|
|||
separator: "-",
|
||||
uniquenessStrategy: "counter",
|
||||
},
|
||||
CRegex.CR_YEAR_SEQUENCE,
|
||||
CRegex.FORMAT_ID_YEAR_SEQUENCE,
|
||||
);
|
||||
|
||||
crimesData.push({
|
||||
|
@ -555,7 +555,7 @@ export class CrimesSeeder {
|
|||
separator: "-",
|
||||
uniquenessStrategy: "counter",
|
||||
},
|
||||
CRegex.CR_YEAR_SEQUENCE,
|
||||
CRegex.FORMAT_ID_YEAR_SEQUENCE,
|
||||
);
|
||||
|
||||
crimesData.push({
|
||||
|
@ -631,7 +631,7 @@ export class CrimesSeeder {
|
|||
separator: "-",
|
||||
uniquenessStrategy: "counter",
|
||||
},
|
||||
CRegex.CR_SEQUENCE_END,
|
||||
CRegex.FORMAT_ID_SEQUENCE_END,
|
||||
);
|
||||
|
||||
crimesData.push({
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import { PrismaClient } from "@prisma/client";
|
||||
import { evidence, PrismaClient } from "@prisma/client";
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { districtCenters } from "../data/jsons/district-center";
|
||||
|
||||
import { createClient } from "../../app/_utils/supabase/client";
|
||||
import db from "../db";
|
||||
import { generateId, generateIdWithDbCounter } from "../../app/_utils/common";
|
||||
import { CRegex } from "../../app/_utils/const/regex";
|
||||
|
||||
export class IncidentLogSeeder {
|
||||
constructor(
|
||||
|
@ -168,6 +170,7 @@ export class IncidentLogSeeder {
|
|||
|
||||
// Create 24 incidents data array
|
||||
const incidentData = [];
|
||||
const evidenceData = [];
|
||||
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const hourOffset = this.getRandomInt(0, 24); // Random hour within last 24 hours
|
||||
|
@ -180,7 +183,10 @@ export class IncidentLogSeeder {
|
|||
const category =
|
||||
categories[Math.floor(Math.random() * categories.length)];
|
||||
|
||||
// Create incident data
|
||||
const incidentId = faker.string.uuid();
|
||||
incidentData.push({
|
||||
id: incidentId, // Generate ID here to reference in evidence
|
||||
user_id: userId,
|
||||
location_id: location.id,
|
||||
category_id: category.id,
|
||||
|
@ -189,6 +195,39 @@ export class IncidentLogSeeder {
|
|||
source: Math.random() > 0.3 ? "resident" : "reporter",
|
||||
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
|
||||
|
@ -198,49 +237,125 @@ export class IncidentLogSeeder {
|
|||
|
||||
console.log(`Created ${createdIncidents.count} incident logs in bulk`);
|
||||
|
||||
// If you need the actual created records, query them after creation
|
||||
const incidents = await this.prisma.incident_logs.findMany({
|
||||
where: {
|
||||
user_id: userId,
|
||||
time: {
|
||||
gte: new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
time: "asc",
|
||||
},
|
||||
// Insert evidence for all incidents
|
||||
if (evidenceData.length > 0) {
|
||||
try {
|
||||
const createdEvidence = await this.prisma.evidence.createMany({
|
||||
data: evidenceData,
|
||||
});
|
||||
|
||||
return incidents;
|
||||
console.log(`Created ${createdEvidence.count} evidence items`);
|
||||
} catch (error) {
|
||||
console.error("Error creating evidence:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
private getRandomInt(min: number, max: number): number {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
// If you need the actual created records, query them after creation
|
||||
// const incidents = await this.prisma.incident_logs.findMany({
|
||||
// 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 = [
|
||||
"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",
|
||||
"Photo of the suspect",
|
||||
"CCTV footage of the incident",
|
||||
"Audio recording of the witness statement",
|
||||
"Police report document",
|
||||
"Screenshot of online threat",
|
||||
"Image of damaged property",
|
||||
"Photo of the crime scene",
|
||||
"Video of the incident in progress",
|
||||
"Documentary evidence supporting the claim",
|
||||
"Witness photograph",
|
||||
"Receipt related to the incident",
|
||||
"Official complaint document",
|
||||
"Map of incident location with notes",
|
||||
"Audio interview with victim",
|
||||
"Security footage timestamp"
|
||||
];
|
||||
|
||||
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
|
||||
* @param centerLat Center latitude
|
||||
|
@ -282,4 +397,31 @@ export class IncidentLogSeeder {
|
|||
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)];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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];
|
||||
}
|
||||
}
|
|
@ -12,18 +12,25 @@ export class PermissionSeeder {
|
|||
try {
|
||||
// Fetch all resources and roles
|
||||
const allResources = await this.prisma.resources.findMany();
|
||||
|
||||
const adminRole = await this.prisma.roles.findUnique({
|
||||
where: { name: 'admin' },
|
||||
});
|
||||
|
||||
const officerRole = await this.prisma.roles.findUnique({
|
||||
where: { name: 'officer' },
|
||||
})
|
||||
|
||||
const viewerRole = await this.prisma.roles.findUnique({
|
||||
where: { name: 'viewer' },
|
||||
});
|
||||
|
||||
const staffRole = await this.prisma.roles.findUnique({
|
||||
where: { name: 'staff' },
|
||||
});
|
||||
|
||||
if (!adminRole || !viewerRole || !staffRole) {
|
||||
console.error('Roles not found. Please seed roles first.');
|
||||
if (!adminRole || !viewerRole || !staffRole || !officerRole) {
|
||||
console.error('One or more roles not found. Please seed roles first.');
|
||||
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!');
|
||||
} catch (error) {
|
||||
console.error('Error seeding permissions:', error);
|
||||
|
|
|
@ -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 $$;
|
|
@ -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;
|
Loading…
Reference in New Issue