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,
|
AuthenticationError,
|
||||||
UnauthenticatedError,
|
UnauthenticatedError,
|
||||||
} from '@/src/entities/errors/auth';
|
} from '@/src/entities/errors/auth';
|
||||||
import { InputParseError, NotFoundError } from '@/src/entities/errors/common';
|
import { NotFoundError } from '@/src/entities/errors/common';
|
||||||
import { districtsGeoJson } from '@/prisma/data/geojson/jember/districts';
|
|
||||||
import { calculateCentroid } from '@/app/_lib/transformGeoJSON';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize district data in the database from GeoJSON
|
* Initialize district data in the database from GeoJSON
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { ICrimes } from "@/app/_utils/types/crimes";
|
import type { ICrimes } from "@/app/_utils/types/crimes";
|
||||||
import {
|
import {
|
||||||
BASE_BEARING,
|
BASE_BEARING,
|
||||||
|
@ -14,6 +14,7 @@ import IncidentPopup from "../pop-up/incident-popup";
|
||||||
import type mapboxgl from "mapbox-gl";
|
import type mapboxgl from "mapbox-gl";
|
||||||
import type { MapGeoJSONFeature, MapMouseEvent } from "react-map-gl/mapbox";
|
import type { MapGeoJSONFeature, MapMouseEvent } from "react-map-gl/mapbox";
|
||||||
import { manageLayerVisibility } from "@/app/_utils/map/layer-visibility";
|
import { manageLayerVisibility } from "@/app/_utils/map/layer-visibility";
|
||||||
|
import { getCategoryColor } from "@/app/_utils/colors";
|
||||||
|
|
||||||
interface IAllIncidentsLayerProps {
|
interface IAllIncidentsLayerProps {
|
||||||
visible?: boolean;
|
visible?: boolean;
|
||||||
|
@ -155,6 +156,22 @@ export default function AllIncidentsLayer(
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map || !visible) return;
|
if (!map || !visible) return;
|
||||||
|
|
||||||
|
// Get unique categories from crime data for more accurate color mapping
|
||||||
|
const uniqueCategories = new Set<string>();
|
||||||
|
crimes.forEach(crime => {
|
||||||
|
crime.crime_incidents.forEach(incident => {
|
||||||
|
if (incident.crime_categories?.name) {
|
||||||
|
uniqueCategories.add(incident.crime_categories.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pre-compute category colors for better performance
|
||||||
|
const categoryColorMap: Record<string, string> = {};
|
||||||
|
uniqueCategories.forEach(category => {
|
||||||
|
categoryColorMap[category] = getCategoryColor(category);
|
||||||
|
});
|
||||||
|
|
||||||
// Convert incidents to GeoJSON format
|
// Convert incidents to GeoJSON format
|
||||||
const allIncidents = crimes.flatMap((crime) => {
|
const allIncidents = crimes.flatMap((crime) => {
|
||||||
return crime.crime_incidents
|
return crime.crime_incidents
|
||||||
|
@ -240,20 +257,12 @@ export default function AllIncidentsLayer(
|
||||||
"circle-color": [
|
"circle-color": [
|
||||||
"match",
|
"match",
|
||||||
["get", "category"],
|
["get", "category"],
|
||||||
"Theft",
|
// Use dynamic mapping from pre-computed category colors
|
||||||
"#FF5733",
|
...Object.entries(categoryColorMap).flatMap(
|
||||||
"Assault",
|
([category, color]) => [category, color]
|
||||||
"#C70039",
|
),
|
||||||
"Robbery",
|
|
||||||
"#900C3F",
|
|
||||||
"Burglary",
|
|
||||||
"#581845",
|
|
||||||
"Fraud",
|
|
||||||
"#FFC300",
|
|
||||||
"Homicide",
|
|
||||||
"#FF0000",
|
|
||||||
// Default color for other categories
|
// Default color for other categories
|
||||||
"#2874A6",
|
getCategoryColor("Unknown")
|
||||||
],
|
],
|
||||||
"circle-opacity": 0.4,
|
"circle-opacity": 0.4,
|
||||||
"circle-blur": 0.6,
|
"circle-blur": 0.6,
|
||||||
|
@ -278,20 +287,12 @@ export default function AllIncidentsLayer(
|
||||||
"circle-color": [
|
"circle-color": [
|
||||||
"match",
|
"match",
|
||||||
["get", "category"],
|
["get", "category"],
|
||||||
"Theft",
|
// Use dynamic mapping from pre-computed category colors
|
||||||
"#FF5733",
|
...Object.entries(categoryColorMap).flatMap(
|
||||||
"Assault",
|
([category, color]) => [category, color]
|
||||||
"#C70039",
|
),
|
||||||
"Robbery",
|
|
||||||
"#900C3F",
|
|
||||||
"Burglary",
|
|
||||||
"#581845",
|
|
||||||
"Fraud",
|
|
||||||
"#FFC300",
|
|
||||||
"Homicide",
|
|
||||||
"#FF0000",
|
|
||||||
// Default color for other categories
|
// Default color for other categories
|
||||||
"#2874A6",
|
getCategoryColor("Unknown")
|
||||||
],
|
],
|
||||||
"circle-stroke-width": 1,
|
"circle-stroke-width": 1,
|
||||||
"circle-stroke-color": "#FFFFFF",
|
"circle-stroke-color": "#FFFFFF",
|
||||||
|
@ -317,20 +318,12 @@ export default function AllIncidentsLayer(
|
||||||
"circle-color": [
|
"circle-color": [
|
||||||
"match",
|
"match",
|
||||||
["get", "category"],
|
["get", "category"],
|
||||||
"Theft",
|
// Use dynamic mapping from pre-computed category colors
|
||||||
"#FF5733",
|
...Object.entries(categoryColorMap).flatMap(
|
||||||
"Assault",
|
([category, color]) => [category, color]
|
||||||
"#C70039",
|
),
|
||||||
"Robbery",
|
|
||||||
"#900C3F",
|
|
||||||
"Burglary",
|
|
||||||
"#581845",
|
|
||||||
"Fraud",
|
|
||||||
"#FFC300",
|
|
||||||
"Homicide",
|
|
||||||
"#FF0000",
|
|
||||||
// Default color for other categories
|
// Default color for other categories
|
||||||
"#2874A6",
|
getCategoryColor("Unknown")
|
||||||
],
|
],
|
||||||
"circle-stroke-width": 1,
|
"circle-stroke-width": 1,
|
||||||
"circle-stroke-color": "#FFFFFF",
|
"circle-stroke-color": "#FFFFFF",
|
||||||
|
|
|
@ -467,15 +467,13 @@ export default function Layers({
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const showHeatmapLayer = activeControl === "heatmap" &&
|
const showHeatmapLayer = activeControl === "heatmap" && sourceType !== "cbu";
|
||||||
sourceType !== "cbu";
|
|
||||||
const showUnitsLayer = activeControl === "units";
|
const showUnitsLayer = activeControl === "units";
|
||||||
const showTimelineLayer = activeControl === "timeline";
|
const showTimelineLayer = activeControl === "timeline";
|
||||||
const showRecentIncidents = activeControl === "recents";
|
const showRecentIncidents = activeControl === "recents";
|
||||||
const showAllIncidents = activeControl === "incidents"; // Use the "incidents" tooltip for all incidents
|
const showAllIncidents = activeControl === "incidents"; // Use the "incidents" tooltip for all incidents
|
||||||
const showDistrictFill = activeControl === "incidents" ||
|
const showDistrictFill = activeControl === "clusters";
|
||||||
activeControl === "clusters" ||
|
|
||||||
activeControl === "recents";
|
|
||||||
const showIncidentMarkers = activeControl !== "heatmap" &&
|
const showIncidentMarkers = activeControl !== "heatmap" &&
|
||||||
activeControl !== "timeline" && sourceType !== "cbu";
|
activeControl !== "timeline" && sourceType !== "cbu";
|
||||||
|
|
||||||
|
@ -504,7 +502,7 @@ export default function Layers({
|
||||||
"crime-points",
|
"crime-points",
|
||||||
"crime-count-labels",
|
"crime-count-labels",
|
||||||
];
|
];
|
||||||
const unclusteredLayerIds = ["unclustered-point"];
|
|
||||||
const allIncidentsLayerIds = [
|
const allIncidentsLayerIds = [
|
||||||
"all-incidents-pulse",
|
"all-incidents-pulse",
|
||||||
"all-incidents-circles",
|
"all-incidents-circles",
|
||||||
|
@ -535,9 +533,7 @@ export default function Layers({
|
||||||
manageLayerVisibility(mapboxMap, allIncidentsLayerIds, false);
|
manageLayerVisibility(mapboxMap, allIncidentsLayerIds, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeControl !== "incidents" && activeControl !== "recents") {
|
|
||||||
manageLayerVisibility(mapboxMap, unclusteredLayerIds, false);
|
|
||||||
}
|
|
||||||
}, [activeControl, mapboxMap]);
|
}, [activeControl, mapboxMap]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -10,7 +10,8 @@ import db from '../../prisma/db';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
import { CRIME_RATE_COLORS } from './const/map';
|
import { CRIME_RATE_COLORS } from './const/map';
|
||||||
import { districtsGeoJson } from '../../prisma/data/geojson/jember/districts';
|
import { districtsGeoJson } from '../../prisma/data/geojson/jember/districts-geojson';
|
||||||
|
|
||||||
|
|
||||||
// Used to track generated IDs
|
// Used to track generated IDs
|
||||||
const usedIdRegistry = new Set<string>();
|
const usedIdRegistry = new Set<string>();
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
export class CRegex {
|
export class CRegex {
|
||||||
static readonly PHONE_REGEX = /^(\+62|62|0)[8][1-9][0-9]{6,11}$/;
|
static readonly PHONE_REGEX = /^(\+62|62|0)[8][1-9][0-9]{6,11}$/;
|
||||||
static readonly BAN_DURATION_REGEX = /^(\d+(ns|us|µs|ms|s|m|h)|none)$/;
|
static readonly BAN_DURATION_REGEX = /^(\d+(ns|us|µs|ms|s|m|h)|none)$/;
|
||||||
static readonly CR_YEAR_SEQUENCE = /(\d{4})(?=-\d{4}$)/;
|
static readonly FORMAT_ID_YEAR_SEQUENCE = /(\d{4})(?=-\d{4}$)/;
|
||||||
static readonly CR_SEQUENCE_END = /(\d{4})$/;
|
static readonly FORMAT_ID_SEQUENCE_END = /(\d{4})$/;
|
||||||
|
static readonly FORMAT_ID_SEQUENCE = /(\d{4})(?=-\d{2}$)/;
|
||||||
|
static readonly PATROL_UNIT_ID_REGEX = /^PU-(\w{3,})(\d{2})$/;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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: {
|
attributes: {
|
||||||
fields: ['id', 'action', 'resource_id', 'role_id', 'created_at', 'updated_at']
|
fields: ['id', 'action', 'resource_id', 'role_id', 'created_at', 'updated_at']
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'units',
|
||||||
|
description: 'Police unit management',
|
||||||
|
attributes: {
|
||||||
|
fields: ['code_unit', 'district_id', 'name', 'description', 'type', 'created_at', 'updated_at', 'address', 'land_area', 'latitude', 'longitude', 'location', 'city_id', 'phone']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'patrol_units',
|
||||||
|
description: 'Patrol unit management',
|
||||||
|
attributes: {
|
||||||
|
fields: ['id', 'unit_id', 'location_id', 'name', 'type', 'status', 'radius', 'created_at']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'officers',
|
||||||
|
description: 'Police officer management',
|
||||||
|
attributes: {
|
||||||
|
fields: ['id', 'unit_id', 'role_id', 'nrp', 'name', 'rank', 'position', 'phone', 'email', 'valid_until', 'created_at', 'updated_at', 'avatar', 'qr_code', 'patrol_unitsId']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'unit_statistics',
|
||||||
|
description: 'Unit statistics management',
|
||||||
|
attributes: {
|
||||||
|
fields: ['id', 'unit_id', 'year', 'month', 'crime_solved', 'crime_total', 'performance_index', 'created_at', 'updated_at']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'incident_logs',
|
||||||
|
description: 'Incident logs management',
|
||||||
|
attributes: {
|
||||||
|
fields: ['id', 'user_id', 'location_id', 'category_id', 'description', 'source', 'time', 'verified', 'created_at', 'updated_at']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'evidence',
|
||||||
|
description: 'Incident evidence management',
|
||||||
|
attributes: {
|
||||||
|
fields: ['id', 'incident_id', 'type', 'url', 'description', 'caption', 'metadata', 'uploaded_at']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'events',
|
||||||
|
description: 'Events management',
|
||||||
|
attributes: {
|
||||||
|
fields: ['id', 'name', 'description', 'code', 'created_at', 'user_id']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'sessions',
|
||||||
|
description: 'User session management',
|
||||||
|
attributes: {
|
||||||
|
fields: ['id', 'user_id', 'event_id', 'status', 'created_at']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'locations',
|
||||||
|
description: 'Location data management',
|
||||||
|
attributes: {
|
||||||
|
fields: ['id', 'district_id', 'event_id', 'address', 'type', 'latitude', 'longitude', 'land_area', 'polygon', 'geometry', 'created_at', 'updated_at', 'location', 'distance_to_unit']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'location_logs',
|
||||||
|
description: 'Location logs management',
|
||||||
|
attributes: {
|
||||||
|
fields: ['id', 'user_id', 'latitude', 'longitude', 'accuracy', 'heading', 'speed', 'altitude', 'created_at']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'logs',
|
||||||
|
description: 'System logs management',
|
||||||
|
attributes: {
|
||||||
|
fields: ['id', 'action', 'entity', 'entity_id', 'details', 'ip_address', 'user_agent', 'created_at']
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
|
@ -3,6 +3,10 @@ export const rolesData = [
|
||||||
name: 'admin',
|
name: 'admin',
|
||||||
description: 'Administrator with full access to all features.',
|
description: 'Administrator with full access to all features.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'officer',
|
||||||
|
description: 'Police officer with access to patrol and report features.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'viewer',
|
name: 'viewer',
|
||||||
description: 'Read-only access to the data.',
|
description: 'Read-only access to the data.',
|
||||||
|
|
|
@ -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)
|
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
||||||
permissions permissions[]
|
permissions permissions[]
|
||||||
users users[]
|
users users[]
|
||||||
|
officers officers[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model sessions {
|
model sessions {
|
||||||
|
@ -173,12 +174,12 @@ model crimes {
|
||||||
method String? @db.VarChar(100)
|
method String? @db.VarChar(100)
|
||||||
month Int?
|
month Int?
|
||||||
number_of_crime Int @default(0)
|
number_of_crime Int @default(0)
|
||||||
crime_cleared Int @default(0)
|
|
||||||
avg_crime Float @default(0)
|
|
||||||
score Float @default(0)
|
score Float @default(0)
|
||||||
updated_at DateTime? @default(now()) @db.Timestamptz(6)
|
updated_at DateTime? @default(now()) @db.Timestamptz(6)
|
||||||
year Int?
|
year Int?
|
||||||
source_type String? @db.VarChar(100)
|
source_type String? @db.VarChar(100)
|
||||||
|
crime_cleared Int @default(0)
|
||||||
|
avg_crime Float @default(0)
|
||||||
crime_incidents crime_incidents[]
|
crime_incidents crime_incidents[]
|
||||||
districts districts @relation(fields: [district_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
districts districts @relation(fields: [district_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
|
||||||
|
@ -230,20 +231,22 @@ model locations {
|
||||||
latitude Float
|
latitude Float
|
||||||
longitude Float
|
longitude Float
|
||||||
land_area Float?
|
land_area Float?
|
||||||
distance_to_unit Float?
|
|
||||||
polygon Unsupported("geometry")?
|
polygon Unsupported("geometry")?
|
||||||
geometry Unsupported("geometry")?
|
geometry Unsupported("geometry")?
|
||||||
created_at DateTime? @default(now()) @db.Timestamptz(6)
|
created_at DateTime? @default(now()) @db.Timestamptz(6)
|
||||||
updated_at DateTime? @default(now()) @db.Timestamptz(6)
|
updated_at DateTime? @default(now()) @db.Timestamptz(6)
|
||||||
location Unsupported("geography")
|
location Unsupported("geography")
|
||||||
|
distance_to_unit Float?
|
||||||
crime_incidents crime_incidents[]
|
crime_incidents crime_incidents[]
|
||||||
incident_logs incident_logs[]
|
incident_logs incident_logs[]
|
||||||
districts districts @relation(fields: [district_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
districts districts @relation(fields: [district_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
event events @relation(fields: [event_id], references: [id])
|
event events @relation(fields: [event_id], references: [id])
|
||||||
|
patrol_units patrol_units[]
|
||||||
|
|
||||||
@@index([district_id], map: "idx_locations_district_id")
|
@@index([district_id], map: "idx_locations_district_id")
|
||||||
@@index([type], map: "idx_locations_type")
|
@@index([type], map: "idx_locations_type")
|
||||||
@@index([location], map: "idx_locations_geography", type: Gist)
|
@@index([location], map: "idx_locations_geography", type: Gist)
|
||||||
|
@@index([location], map: "idx_locations_location_gist", type: Gist)
|
||||||
}
|
}
|
||||||
|
|
||||||
model incident_logs {
|
model incident_logs {
|
||||||
|
@ -260,40 +263,110 @@ model incident_logs {
|
||||||
crime_categories crime_categories @relation(fields: [category_id], references: [id], map: "fk_incident_category")
|
crime_categories crime_categories @relation(fields: [category_id], references: [id], map: "fk_incident_category")
|
||||||
locations locations @relation(fields: [location_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
locations locations @relation(fields: [location_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
user users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
user users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
evidence evidence[]
|
||||||
|
|
||||||
@@index([category_id], map: "idx_incident_logs_category_id")
|
@@index([category_id], map: "idx_incident_logs_category_id")
|
||||||
@@index([time], map: "idx_incident_logs_time")
|
@@index([time], map: "idx_incident_logs_time")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model evidence {
|
||||||
|
id String @id @unique @db.VarChar(20)
|
||||||
|
incident_id String @db.Uuid
|
||||||
|
type String @db.VarChar(50) // contoh: photo, video, document, images
|
||||||
|
url String @db.Text
|
||||||
|
description String? @db.VarChar(255)
|
||||||
|
caption String? @db.VarChar(255)
|
||||||
|
metadata Json?
|
||||||
|
uploaded_at DateTime? @default(now()) @db.Timestamptz(6)
|
||||||
|
|
||||||
|
incident incident_logs @relation(fields: [incident_id], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([incident_id], map: "idx_evidence_incident_id")
|
||||||
|
}
|
||||||
|
|
||||||
model units {
|
model units {
|
||||||
code_unit String @id @unique @db.VarChar(20)
|
code_unit String @id @unique @db.VarChar(20)
|
||||||
district_id String? @unique @db.VarChar(20)
|
district_id String? @unique @db.VarChar(20)
|
||||||
city_id String @db.VarChar(20)
|
|
||||||
name String @db.VarChar(100)
|
name String @db.VarChar(100)
|
||||||
description String?
|
description String?
|
||||||
type unit_type
|
type unit_type
|
||||||
created_at DateTime? @default(now()) @db.Timestamptz(6)
|
created_at DateTime? @default(now()) @db.Timestamptz(6)
|
||||||
updated_at DateTime? @default(now()) @db.Timestamptz(6)
|
updated_at DateTime? @default(now()) @db.Timestamptz(6)
|
||||||
address String?
|
address String?
|
||||||
phone String?
|
|
||||||
land_area Float?
|
land_area Float?
|
||||||
latitude Float
|
latitude Float
|
||||||
longitude Float
|
longitude Float
|
||||||
location Unsupported("geography")
|
location Unsupported("geography")
|
||||||
|
city_id String @db.VarChar(20)
|
||||||
|
phone String?
|
||||||
unit_statistics unit_statistics[]
|
unit_statistics unit_statistics[]
|
||||||
districts districts? @relation(fields: [district_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
|
||||||
cities cities @relation(fields: [city_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
cities cities @relation(fields: [city_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
districts districts? @relation(fields: [district_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
officers officers[]
|
||||||
|
patrol_units patrol_units[]
|
||||||
|
|
||||||
@@index([name], map: "idx_units_name")
|
@@index([name], map: "idx_units_name")
|
||||||
@@index([type], map: "idx_units_type")
|
@@index([type], map: "idx_units_type")
|
||||||
@@index([code_unit], map: "idx_units_code_unit")
|
@@index([code_unit], map: "idx_units_code_unit")
|
||||||
@@index([district_id], map: "idx_units_district_id")
|
@@index([district_id], map: "idx_units_district_id")
|
||||||
@@index([location], map: "idx_unit_location", type: Gist)
|
@@index([location], map: "idx_unit_location", type: Gist)
|
||||||
|
@@index([district_id, location], map: "idx_units_location_district")
|
||||||
|
@@index([location], map: "idx_units_location_gist", type: Gist)
|
||||||
|
@@index([location], type: Gist)
|
||||||
|
@@index([location], map: "units_location_idx1", type: Gist)
|
||||||
|
@@index([location], map: "units_location_idx2", type: Gist)
|
||||||
|
}
|
||||||
|
|
||||||
|
model patrol_units {
|
||||||
|
id String @id @unique @db.VarChar(100)
|
||||||
|
unit_id String @db.VarChar(20)
|
||||||
|
location_id String @db.Uuid
|
||||||
|
name String @db.VarChar(100)
|
||||||
|
type String @db.VarChar(50)
|
||||||
|
status String @db.VarChar(50)
|
||||||
|
radius Float
|
||||||
|
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||||
|
|
||||||
|
members officers[]
|
||||||
|
location locations @relation(fields: [location_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
unit units @relation(fields: [unit_id], references: [code_unit], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@index([unit_id], map: "idx_patrol_units_unit_id")
|
||||||
|
@@index([location_id], map: "idx_patrol_units_location_id")
|
||||||
|
@@index([name], map: "idx_patrol_units_name")
|
||||||
|
@@index([type], map: "idx_patrol_units_type")
|
||||||
|
@@index([status], map: "idx_patrol_units_status")
|
||||||
|
}
|
||||||
|
|
||||||
|
model officers {
|
||||||
|
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||||
|
unit_id String @db.VarChar(20)
|
||||||
|
role_id String @db.Uuid
|
||||||
|
patrol_unit_id String @db.VarChar(100)
|
||||||
|
nrp String @unique @db.VarChar(100)
|
||||||
|
name String @db.VarChar(100)
|
||||||
|
rank String? @db.VarChar(100)
|
||||||
|
position String? @db.VarChar(100)
|
||||||
|
phone String? @db.VarChar(100)
|
||||||
|
email String? @db.VarChar(255)
|
||||||
|
avatar String?
|
||||||
|
valid_until DateTime?
|
||||||
|
qr_code String?
|
||||||
|
created_at DateTime? @default(now()) @db.Timestamptz(6)
|
||||||
|
updated_at DateTime? @default(now()) @db.Timestamptz(6)
|
||||||
|
units units @relation(fields: [unit_id], references: [code_unit], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
roles roles @relation(fields: [role_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
patrol_units patrol_units? @relation(fields: [patrol_unit_id], references: [id])
|
||||||
|
|
||||||
|
@@index([unit_id], map: "idx_officers_unit_id")
|
||||||
|
@@index([nrp], map: "idx_officers_nrp")
|
||||||
|
@@index([name], map: "idx_officers_name")
|
||||||
|
@@index([rank], map: "idx_officers_rank")
|
||||||
|
@@index([position], map: "idx_officers_position")
|
||||||
}
|
}
|
||||||
|
|
||||||
model unit_statistics {
|
model unit_statistics {
|
||||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||||
code_unit String @db.VarChar(20)
|
|
||||||
crime_total Int
|
crime_total Int
|
||||||
crime_cleared Int
|
crime_cleared Int
|
||||||
percentage Float?
|
percentage Float?
|
||||||
|
@ -302,6 +375,7 @@ model unit_statistics {
|
||||||
year Int
|
year Int
|
||||||
created_at DateTime? @default(now()) @db.Timestamptz(6)
|
created_at DateTime? @default(now()) @db.Timestamptz(6)
|
||||||
updated_at DateTime? @default(now()) @db.Timestamptz(6)
|
updated_at DateTime? @default(now()) @db.Timestamptz(6)
|
||||||
|
code_unit String @db.VarChar(20)
|
||||||
units units @relation(fields: [code_unit], references: [code_unit], onDelete: Cascade, onUpdate: NoAction)
|
units units @relation(fields: [code_unit], references: [code_unit], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
|
||||||
@@unique([code_unit, month, year])
|
@@unique([code_unit, month, year])
|
||||||
|
|
|
@ -9,6 +9,8 @@ import { DemographicsSeeder } from './seeds/demographic';
|
||||||
import { CrimeCategoriesSeeder } from './seeds/crime-category';
|
import { CrimeCategoriesSeeder } from './seeds/crime-category';
|
||||||
|
|
||||||
import { UnitSeeder } from './seeds/units';
|
import { UnitSeeder } from './seeds/units';
|
||||||
|
import { PatrolUnitsSeeder } from './seeds/patrol-units';
|
||||||
|
import { OfficersSeeder } from './seeds/officers';
|
||||||
import { CrimesSeeder } from './seeds/crimes';
|
import { CrimesSeeder } from './seeds/crimes';
|
||||||
import { CrimeIncidentsByUnitSeeder } from './seeds/crime-incidents';
|
import { CrimeIncidentsByUnitSeeder } from './seeds/crime-incidents';
|
||||||
import { CrimeIncidentsByTypeSeeder } from './seeds/crime-incidents-cbt';
|
import { CrimeIncidentsByTypeSeeder } from './seeds/crime-incidents-cbt';
|
||||||
|
@ -31,16 +33,18 @@ class DatabaseSeeder {
|
||||||
|
|
||||||
// Daftar semua seeders di sini
|
// Daftar semua seeders di sini
|
||||||
this.seeders = [
|
this.seeders = [
|
||||||
new RoleSeeder(prisma),
|
// new RoleSeeder(prisma),
|
||||||
new ResourceSeeder(prisma),
|
// new ResourceSeeder(prisma),
|
||||||
new PermissionSeeder(prisma),
|
// new PermissionSeeder(prisma),
|
||||||
new CrimeCategoriesSeeder(prisma),
|
// new CrimeCategoriesSeeder(prisma),
|
||||||
new GeoJSONSeeder(prisma),
|
// new GeoJSONSeeder(prisma),
|
||||||
new UnitSeeder(prisma),
|
// new UnitSeeder(prisma),
|
||||||
new DemographicsSeeder(prisma),
|
// new PatrolUnitsSeeder(prisma),
|
||||||
new CrimesSeeder(prisma),
|
// new OfficersSeeder(prisma),
|
||||||
|
// new DemographicsSeeder(prisma),
|
||||||
|
// new CrimesSeeder(prisma),
|
||||||
// new CrimeIncidentsByUnitSeeder(prisma),
|
// new CrimeIncidentsByUnitSeeder(prisma),
|
||||||
new CrimeIncidentsByTypeSeeder(prisma),
|
// new CrimeIncidentsByTypeSeeder(prisma),
|
||||||
new IncidentLogSeeder(prisma),
|
new IncidentLogSeeder(prisma),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,8 @@ import * as path from "path";
|
||||||
import { CRegex } from "../../app/_utils/const/regex";
|
import { CRegex } from "../../app/_utils/const/regex";
|
||||||
|
|
||||||
import { districtCenters } from "../data/jsons/district-center";
|
import { districtCenters } from "../data/jsons/district-center";
|
||||||
|
import { districtsGeoJson } from "../data/geojson/jember/districts-geojson";
|
||||||
|
|
||||||
|
|
||||||
type ICreateLocations = {
|
type ICreateLocations = {
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -67,7 +69,7 @@ export class CrimeIncidentsByTypeSeeder {
|
||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaClient,
|
private prisma: PrismaClient,
|
||||||
private supabase = createClient(),
|
private supabase = createClient(),
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
private async loadCrimeMonthlyData(): Promise<void> {
|
private async loadCrimeMonthlyData(): Promise<void> {
|
||||||
const jsonFilePath = path.resolve(
|
const jsonFilePath = path.resolve(
|
||||||
|
@ -389,13 +391,11 @@ export class CrimeIncidentsByTypeSeeder {
|
||||||
`${placeType} ${district.name}, ${streetName}, Jember`;
|
`${placeType} ${district.name}, ${streetName}, Jember`;
|
||||||
break;
|
break;
|
||||||
case 2:
|
case 2:
|
||||||
randomAddress = `${streetName} Blok ${
|
randomAddress = `${streetName} Blok ${String.fromCharCode(
|
||||||
String.fromCharCode(
|
65 + Math.floor(Math.random() * 26),
|
||||||
65 + Math.floor(Math.random() * 26),
|
)
|
||||||
)
|
}-${Math.floor(Math.random() * 20) + 1
|
||||||
}-${
|
}, ${district.name}, Jember`;
|
||||||
Math.floor(Math.random() * 20) + 1
|
|
||||||
}, ${district.name}, Jember`;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -425,7 +425,7 @@ export class CrimeIncidentsByTypeSeeder {
|
||||||
separator: "-",
|
separator: "-",
|
||||||
uniquenessStrategy: "counter",
|
uniquenessStrategy: "counter",
|
||||||
},
|
},
|
||||||
CRegex.CR_YEAR_SEQUENCE,
|
CRegex.FORMAT_ID_YEAR_SEQUENCE,
|
||||||
);
|
);
|
||||||
|
|
||||||
const status = resolvedCount < crimesCleared
|
const status = resolvedCount < crimesCleared
|
||||||
|
@ -717,37 +717,139 @@ export class CrimeIncidentsByTypeSeeder {
|
||||||
const points = [];
|
const points = [];
|
||||||
const districtNameLower = districtName.toLowerCase();
|
const districtNameLower = districtName.toLowerCase();
|
||||||
|
|
||||||
|
// Find the district feature in the GeoJSON
|
||||||
|
const districtFeature = districtsGeoJson.features.find(
|
||||||
|
(feature) =>
|
||||||
|
feature.properties &&
|
||||||
|
feature.properties.kecamatan &&
|
||||||
|
feature.properties.kecamatan.toLowerCase() === districtNameLower
|
||||||
|
);
|
||||||
|
|
||||||
|
// Helper to flatten all coordinates from a GeoJSON geometry
|
||||||
|
function extractCoordinates(geometry: any): Array<number[]> {
|
||||||
|
if (!geometry) return [];
|
||||||
|
|
||||||
|
if (geometry.type === "Polygon") {
|
||||||
|
// geometry.coordinates: [ [ [lng, lat, z?], ... ] ]
|
||||||
|
return geometry.coordinates.flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (geometry.type === "MultiPolygon") {
|
||||||
|
// geometry.coordinates: [ [ [ [lng, lat, z?], ... ] ], ... ]
|
||||||
|
return geometry.coordinates.flat(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use coordinates from GeoJSON if available
|
||||||
|
if (districtFeature && districtFeature.geometry) {
|
||||||
|
// console.log(`Found GeoJSON for district: ${districtName}`);
|
||||||
|
|
||||||
|
const allCoords = extractCoordinates(districtFeature.geometry);
|
||||||
|
|
||||||
|
// Filter valid coordinates (handle both 2D and 3D coordinates)
|
||||||
|
const coords = allCoords.filter(c =>
|
||||||
|
Array.isArray(c) &&
|
||||||
|
c.length >= 2 &&
|
||||||
|
typeof c[0] === 'number' &&
|
||||||
|
typeof c[1] === 'number'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (coords.length === 0) {
|
||||||
|
console.warn(`No valid coordinates found in GeoJSON for district: ${districtName}`);
|
||||||
|
console.log("Geometry structure:", JSON.stringify(districtFeature.geometry, null, 2));
|
||||||
|
} else {
|
||||||
|
// console.log(`Found ${coords.length} valid coordinates for district: ${districtName}`);
|
||||||
|
|
||||||
|
// If enough points, sample randomly; otherwise, interpolate
|
||||||
|
if (numPoints <= coords.length) {
|
||||||
|
// Shuffle and pick numPoints
|
||||||
|
const shuffled = [...coords].sort(() => 0.5 - Math.random());
|
||||||
|
for (let i = 0; i < numPoints; i++) {
|
||||||
|
const coord = shuffled[i];
|
||||||
|
// Extract lng and lat (first two values), regardless of whether it's 2D or 3D
|
||||||
|
const lng = coord[0];
|
||||||
|
const lat = coord[1];
|
||||||
|
const noise = 0.0002 * (Math.random() - 0.5);
|
||||||
|
points.push({
|
||||||
|
latitude: lat + noise,
|
||||||
|
longitude: lng + noise,
|
||||||
|
radius: 100 + Math.random() * 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Use all points, then interpolate between random pairs for the rest
|
||||||
|
coords.forEach((coord) => {
|
||||||
|
const lng = coord[0];
|
||||||
|
const lat = coord[1];
|
||||||
|
const noise = 0.0002 * (Math.random() - 0.5);
|
||||||
|
points.push({
|
||||||
|
latitude: lat + noise,
|
||||||
|
longitude: lng + noise,
|
||||||
|
radius: 100 + Math.random() * 400,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = coords.length; i < numPoints; i++) {
|
||||||
|
// Pick two random points and interpolate
|
||||||
|
const idx1 = Math.floor(Math.random() * coords.length);
|
||||||
|
let idx2 = Math.floor(Math.random() * coords.length);
|
||||||
|
if (idx2 === idx1) idx2 = (idx2 + 1) % coords.length;
|
||||||
|
|
||||||
|
const coord1 = coords[idx1];
|
||||||
|
const coord2 = coords[idx2];
|
||||||
|
|
||||||
|
const lng1 = coord1[0];
|
||||||
|
const lat1 = coord1[1];
|
||||||
|
const lng2 = coord2[0];
|
||||||
|
const lat2 = coord2[1];
|
||||||
|
|
||||||
|
const t = Math.random();
|
||||||
|
const noise = 0.0003 * (Math.random() - 0.5);
|
||||||
|
points.push({
|
||||||
|
latitude: lat1 * t + lat2 * (1 - t) + noise,
|
||||||
|
longitude: lng1 * t + lng2 * (1 - t) + noise,
|
||||||
|
radius: 100 + Math.random() * 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(`No GeoJSON feature found for district: ${districtName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: use district center if GeoJSON not available
|
||||||
const districtCenter = districtCenters.find(
|
const districtCenter = districtCenters.find(
|
||||||
(center) => center.kecamatan.toLowerCase() === districtNameLower,
|
(center) => center.kecamatan.toLowerCase() === districtNameLower,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!districtCenter) {
|
if (districtCenter) {
|
||||||
return [];
|
console.log(`Using district center fallback for: ${districtName}`);
|
||||||
}
|
const centerLat = districtCenter.lat;
|
||||||
|
const centerLng = districtCenter.lng;
|
||||||
|
const estimatedRadiusKm = Math.sqrt(landArea / Math.PI) / 1000;
|
||||||
|
const radiusKm = Math.min(3, Math.max(0.5, estimatedRadiusKm));
|
||||||
|
const radiusDeg = radiusKm / 111;
|
||||||
|
|
||||||
const centerLat = districtCenter.lat;
|
for (let i = 0; i < numPoints; i++) {
|
||||||
const centerLng = districtCenter.lng;
|
const angle = Math.random() * 2 * Math.PI;
|
||||||
|
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;
|
||||||
|
|
||||||
// Skala kecil: radius hanya 0.5 km dari center (sekitar 500 meter)
|
points.push({
|
||||||
const radiusKm = 0.5;
|
latitude,
|
||||||
const radiusDeg = radiusKm / 111;
|
longitude,
|
||||||
|
radius: pointRadius,
|
||||||
for (let i = 0; i < numPoints; i++) {
|
});
|
||||||
const angle = Math.random() * 2 * Math.PI;
|
}
|
||||||
// Jarak random, lebih padat di tengah
|
} else {
|
||||||
const distance = Math.pow(Math.random(), 1.5) * radiusDeg;
|
console.error(`No data available for district: ${districtName}`);
|
||||||
|
|
||||||
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({
|
|
||||||
latitude,
|
|
||||||
longitude,
|
|
||||||
radius: pointRadius,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return points;
|
return points;
|
||||||
|
|
|
@ -548,7 +548,7 @@ private generateDistributedPoints(
|
||||||
separator: '-',
|
separator: '-',
|
||||||
uniquenessStrategy: 'counter',
|
uniquenessStrategy: 'counter',
|
||||||
},
|
},
|
||||||
CRegex.CR_YEAR_SEQUENCE
|
CRegex.FORMAT_ID_YEAR_SEQUENCE
|
||||||
);
|
);
|
||||||
|
|
||||||
// Determine status based on crime_cleared
|
// Determine status based on crime_cleared
|
||||||
|
|
|
@ -227,7 +227,7 @@ export class CrimesSeeder {
|
||||||
separator: "-",
|
separator: "-",
|
||||||
uniquenessStrategy: "counter",
|
uniquenessStrategy: "counter",
|
||||||
},
|
},
|
||||||
CRegex.CR_YEAR_SEQUENCE,
|
CRegex.FORMAT_ID_YEAR_SEQUENCE,
|
||||||
);
|
);
|
||||||
|
|
||||||
crimesData.push({
|
crimesData.push({
|
||||||
|
@ -311,7 +311,7 @@ export class CrimesSeeder {
|
||||||
separator: "-",
|
separator: "-",
|
||||||
uniquenessStrategy: "counter",
|
uniquenessStrategy: "counter",
|
||||||
},
|
},
|
||||||
CRegex.CR_YEAR_SEQUENCE,
|
CRegex.FORMAT_ID_YEAR_SEQUENCE,
|
||||||
);
|
);
|
||||||
|
|
||||||
crimesData.push({
|
crimesData.push({
|
||||||
|
@ -395,7 +395,7 @@ export class CrimesSeeder {
|
||||||
separator: "-",
|
separator: "-",
|
||||||
uniquenessStrategy: "counter",
|
uniquenessStrategy: "counter",
|
||||||
},
|
},
|
||||||
CRegex.CR_SEQUENCE_END,
|
CRegex.FORMAT_ID_SEQUENCE_END,
|
||||||
);
|
);
|
||||||
|
|
||||||
crimesData.push({
|
crimesData.push({
|
||||||
|
@ -473,7 +473,7 @@ export class CrimesSeeder {
|
||||||
separator: "-",
|
separator: "-",
|
||||||
uniquenessStrategy: "counter",
|
uniquenessStrategy: "counter",
|
||||||
},
|
},
|
||||||
CRegex.CR_YEAR_SEQUENCE,
|
CRegex.FORMAT_ID_YEAR_SEQUENCE,
|
||||||
);
|
);
|
||||||
|
|
||||||
crimesData.push({
|
crimesData.push({
|
||||||
|
@ -555,7 +555,7 @@ export class CrimesSeeder {
|
||||||
separator: "-",
|
separator: "-",
|
||||||
uniquenessStrategy: "counter",
|
uniquenessStrategy: "counter",
|
||||||
},
|
},
|
||||||
CRegex.CR_YEAR_SEQUENCE,
|
CRegex.FORMAT_ID_YEAR_SEQUENCE,
|
||||||
);
|
);
|
||||||
|
|
||||||
crimesData.push({
|
crimesData.push({
|
||||||
|
@ -631,7 +631,7 @@ export class CrimesSeeder {
|
||||||
separator: "-",
|
separator: "-",
|
||||||
uniquenessStrategy: "counter",
|
uniquenessStrategy: "counter",
|
||||||
},
|
},
|
||||||
CRegex.CR_SEQUENCE_END,
|
CRegex.FORMAT_ID_SEQUENCE_END,
|
||||||
);
|
);
|
||||||
|
|
||||||
crimesData.push({
|
crimesData.push({
|
||||||
|
|
|
@ -1,15 +1,17 @@
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { evidence, PrismaClient } from "@prisma/client";
|
||||||
import { faker } from "@faker-js/faker";
|
import { faker } from "@faker-js/faker";
|
||||||
import { districtCenters } from "../data/jsons/district-center";
|
import { districtCenters } from "../data/jsons/district-center";
|
||||||
|
|
||||||
import { createClient } from "../../app/_utils/supabase/client";
|
import { createClient } from "../../app/_utils/supabase/client";
|
||||||
import db from "../db";
|
import db from "../db";
|
||||||
|
import { generateId, generateIdWithDbCounter } from "../../app/_utils/common";
|
||||||
|
import { CRegex } from "../../app/_utils/const/regex";
|
||||||
|
|
||||||
export class IncidentLogSeeder {
|
export class IncidentLogSeeder {
|
||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaClient,
|
private prisma: PrismaClient,
|
||||||
private supabase = createClient(),
|
private supabase = createClient(),
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
// Add run method to satisfy the Seeder interface
|
// Add run method to satisfy the Seeder interface
|
||||||
async run(): Promise<void> {
|
async run(): Promise<void> {
|
||||||
|
@ -99,7 +101,7 @@ export class IncidentLogSeeder {
|
||||||
const districtCenter = districtCenters.find(
|
const districtCenter = districtCenters.find(
|
||||||
(center) =>
|
(center) =>
|
||||||
center.kecamatan.toLowerCase() ===
|
center.kecamatan.toLowerCase() ===
|
||||||
district.name.toLowerCase(),
|
district.name.toLowerCase(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// If we have matching center coordinates, use them as base point
|
// If we have matching center coordinates, use them as base point
|
||||||
|
@ -168,6 +170,7 @@ export class IncidentLogSeeder {
|
||||||
|
|
||||||
// Create 24 incidents data array
|
// Create 24 incidents data array
|
||||||
const incidentData = [];
|
const incidentData = [];
|
||||||
|
const evidenceData = [];
|
||||||
|
|
||||||
for (let i = 0; i < 24; i++) {
|
for (let i = 0; i < 24; i++) {
|
||||||
const hourOffset = this.getRandomInt(0, 24); // Random hour within last 24 hours
|
const hourOffset = this.getRandomInt(0, 24); // Random hour within last 24 hours
|
||||||
|
@ -180,7 +183,10 @@ export class IncidentLogSeeder {
|
||||||
const category =
|
const category =
|
||||||
categories[Math.floor(Math.random() * categories.length)];
|
categories[Math.floor(Math.random() * categories.length)];
|
||||||
|
|
||||||
|
// Create incident data
|
||||||
|
const incidentId = faker.string.uuid();
|
||||||
incidentData.push({
|
incidentData.push({
|
||||||
|
id: incidentId, // Generate ID here to reference in evidence
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
location_id: location.id,
|
location_id: location.id,
|
||||||
category_id: category.id,
|
category_id: category.id,
|
||||||
|
@ -189,6 +195,39 @@ export class IncidentLogSeeder {
|
||||||
source: Math.random() > 0.3 ? "resident" : "reporter",
|
source: Math.random() > 0.3 ? "resident" : "reporter",
|
||||||
verified: Math.random() > 0.5,
|
verified: Math.random() > 0.5,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Generate 1-3 evidence items per incident
|
||||||
|
const numEvidenceItems = this.getRandomInt(1, 3);
|
||||||
|
for (let j = 0; j < numEvidenceItems; j++) {
|
||||||
|
const evidenceType = this.getRandomEvidenceType();
|
||||||
|
// Make sure metadata is a proper Prisma InputJsonValue
|
||||||
|
const metadata = JSON.stringify(this.generateRandomMetadata(evidenceType));
|
||||||
|
|
||||||
|
const newEvidenceId = await generateIdWithDbCounter(
|
||||||
|
"evidence",
|
||||||
|
{
|
||||||
|
prefix: "EV",
|
||||||
|
segments: {
|
||||||
|
sequentialDigits: 4,
|
||||||
|
},
|
||||||
|
format: "{prefix}-{sequence}",
|
||||||
|
separator: "-",
|
||||||
|
uniquenessStrategy: "counter"
|
||||||
|
},
|
||||||
|
CRegex.FORMAT_ID_SEQUENCE
|
||||||
|
);
|
||||||
|
|
||||||
|
evidenceData.push({
|
||||||
|
id: newEvidenceId,
|
||||||
|
incident_id: incidentId,
|
||||||
|
type: evidenceType,
|
||||||
|
url: this.getRandomFileUrl(evidenceType),
|
||||||
|
description: this.getRandomEvidenceDescription(),
|
||||||
|
caption: faker.lorem.sentence(),
|
||||||
|
metadata: metadata as any,
|
||||||
|
uploaded_at: timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bulk insert all incidents at once
|
// Bulk insert all incidents at once
|
||||||
|
@ -198,49 +237,125 @@ export class IncidentLogSeeder {
|
||||||
|
|
||||||
console.log(`Created ${createdIncidents.count} incident logs in bulk`);
|
console.log(`Created ${createdIncidents.count} incident logs in bulk`);
|
||||||
|
|
||||||
|
// Insert evidence for all incidents
|
||||||
|
if (evidenceData.length > 0) {
|
||||||
|
try {
|
||||||
|
const createdEvidence = await this.prisma.evidence.createMany({
|
||||||
|
data: evidenceData,
|
||||||
|
});
|
||||||
|
console.log(`Created ${createdEvidence.count} evidence items`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating evidence:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If you need the actual created records, query them after creation
|
// If you need the actual created records, query them after creation
|
||||||
const incidents = await this.prisma.incident_logs.findMany({
|
// const incidents = await this.prisma.incident_logs.findMany({
|
||||||
where: {
|
// where: {
|
||||||
user_id: userId,
|
// user_id: userId,
|
||||||
time: {
|
// time: {
|
||||||
gte: new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
// gte: new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
orderBy: {
|
// include: {
|
||||||
time: "asc",
|
// evidence: true, // Include evidence in the results
|
||||||
},
|
// },
|
||||||
});
|
// orderBy: {
|
||||||
|
// time: "asc",
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
return incidents;
|
// return incidents;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper methods
|
// New helper methods for evidence generation
|
||||||
private getRandomInt(min: number, max: number): number {
|
private getRandomEvidenceType(): string {
|
||||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
const types = ['image', 'video', 'audio', 'document'];
|
||||||
|
return types[Math.floor(Math.random() * types.length)];
|
||||||
}
|
}
|
||||||
|
|
||||||
private getRandomIncidentDescription(): string {
|
private getRandomFileUrl(type: string): string {
|
||||||
|
const fileTypes: Record<string, string[]> = {
|
||||||
|
'image': ['.jpg', '.png', '.jpeg'],
|
||||||
|
'video': ['.mp4', '.mov', '.avi'],
|
||||||
|
'audio': ['.mp3', '.wav', '.ogg'],
|
||||||
|
'document': ['.pdf', '.docx', '.txt']
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pick a random extension for that type
|
||||||
|
const extensions = fileTypes[type] || fileTypes['image'];
|
||||||
|
const extension = extensions[Math.floor(Math.random() * extensions.length)];
|
||||||
|
|
||||||
|
// Generate a fake file URL
|
||||||
|
if (type === 'image') {
|
||||||
|
return faker.image.url();
|
||||||
|
} else {
|
||||||
|
// For other types, create a plausible URL
|
||||||
|
const fileName = faker.system.fileName().replace(/\.\w+$/, '') + extension;
|
||||||
|
return `https://evidence-storage.sigap.com/${faker.string.uuid()}/${fileName}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRandomEvidenceDescription(): string {
|
||||||
const descriptions = [
|
const descriptions = [
|
||||||
"Suspicious person loitering in the area",
|
"Photo of the suspect",
|
||||||
"Vehicle break-in reported",
|
"CCTV footage of the incident",
|
||||||
"Shoplifting incident at local store",
|
"Audio recording of the witness statement",
|
||||||
"Noise complaint from neighbors",
|
"Police report document",
|
||||||
"Traffic accident with minor injuries",
|
"Screenshot of online threat",
|
||||||
"Vandalism to public property",
|
"Image of damaged property",
|
||||||
"Domestic dispute reported",
|
"Photo of the crime scene",
|
||||||
"Trespassing on private property",
|
"Video of the incident in progress",
|
||||||
"Armed robbery at convenience store",
|
"Documentary evidence supporting the claim",
|
||||||
"Drug-related activity observed",
|
"Witness photograph",
|
||||||
"Assault reported outside nightclub",
|
"Receipt related to the incident",
|
||||||
"Missing person report filed",
|
"Official complaint document",
|
||||||
"Public intoxication incident",
|
"Map of incident location with notes",
|
||||||
"Package theft from doorstep",
|
"Audio interview with victim",
|
||||||
"Illegal dumping observed",
|
"Security footage timestamp"
|
||||||
];
|
];
|
||||||
|
|
||||||
return descriptions[Math.floor(Math.random() * descriptions.length)];
|
return descriptions[Math.floor(Math.random() * descriptions.length)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private generateRandomMetadata(type: string): object {
|
||||||
|
// Generate random metadata based on evidence type
|
||||||
|
switch (type) {
|
||||||
|
case 'image':
|
||||||
|
return {
|
||||||
|
width: faker.number.int({ min: 800, max: 3000 }),
|
||||||
|
height: faker.number.int({ min: 600, max: 2000 }),
|
||||||
|
size: faker.number.int({ min: 100000, max: 5000000 }),
|
||||||
|
format: faker.helpers.arrayElement(['jpg', 'png', 'jpeg']),
|
||||||
|
location: {
|
||||||
|
latitude: faker.location.latitude(),
|
||||||
|
longitude: faker.location.longitude(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
case 'video':
|
||||||
|
return {
|
||||||
|
duration: faker.number.float({ min: 5, max: 180, fractionDigits: 1 }),
|
||||||
|
size: faker.number.int({ min: 1000000, max: 50000000 }),
|
||||||
|
resolution: faker.helpers.arrayElement(['720p', '1080p', '4K']),
|
||||||
|
format: faker.helpers.arrayElement(['mp4', 'mov', 'avi']),
|
||||||
|
};
|
||||||
|
case 'audio':
|
||||||
|
return {
|
||||||
|
duration: faker.number.float({ min: 10, max: 300, fractionDigits: 1 }),
|
||||||
|
size: faker.number.int({ min: 500000, max: 10000000 }),
|
||||||
|
format: faker.helpers.arrayElement(['mp3', 'wav', 'ogg']),
|
||||||
|
};
|
||||||
|
case 'document':
|
||||||
|
return {
|
||||||
|
pages: faker.number.int({ min: 1, max: 20 }),
|
||||||
|
size: faker.number.int({ min: 50000, max: 2000000 }),
|
||||||
|
format: faker.helpers.arrayElement(['pdf', 'docx', 'txt']),
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a random point within a specified radius from a center point
|
* Generates a random point within a specified radius from a center point
|
||||||
* @param centerLat Center latitude
|
* @param centerLat Center latitude
|
||||||
|
@ -282,4 +397,31 @@ export class IncidentLogSeeder {
|
||||||
longitude: randomLng,
|
longitude: randomLng,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper methods
|
||||||
|
private getRandomInt(min: number, max: number): number {
|
||||||
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRandomIncidentDescription(): string {
|
||||||
|
const descriptions = [
|
||||||
|
"Suspicious person loitering in the area",
|
||||||
|
"Vehicle break-in reported",
|
||||||
|
"Shoplifting incident at local store",
|
||||||
|
"Noise complaint from neighbors",
|
||||||
|
"Traffic accident with minor injuries",
|
||||||
|
"Vandalism to public property",
|
||||||
|
"Domestic dispute reported",
|
||||||
|
"Trespassing on private property",
|
||||||
|
"Armed robbery at convenience store",
|
||||||
|
"Drug-related activity observed",
|
||||||
|
"Assault reported outside nightclub",
|
||||||
|
"Missing person report filed",
|
||||||
|
"Public intoxication incident",
|
||||||
|
"Package theft from doorstep",
|
||||||
|
"Illegal dumping observed",
|
||||||
|
];
|
||||||
|
|
||||||
|
return descriptions[Math.floor(Math.random() * descriptions.length)];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
try {
|
||||||
// Fetch all resources and roles
|
// Fetch all resources and roles
|
||||||
const allResources = await this.prisma.resources.findMany();
|
const allResources = await this.prisma.resources.findMany();
|
||||||
|
|
||||||
const adminRole = await this.prisma.roles.findUnique({
|
const adminRole = await this.prisma.roles.findUnique({
|
||||||
where: { name: 'admin' },
|
where: { name: 'admin' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const officerRole = await this.prisma.roles.findUnique({
|
||||||
|
where: { name: 'officer' },
|
||||||
|
})
|
||||||
|
|
||||||
const viewerRole = await this.prisma.roles.findUnique({
|
const viewerRole = await this.prisma.roles.findUnique({
|
||||||
where: { name: 'viewer' },
|
where: { name: 'viewer' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const staffRole = await this.prisma.roles.findUnique({
|
const staffRole = await this.prisma.roles.findUnique({
|
||||||
where: { name: 'staff' },
|
where: { name: 'staff' },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!adminRole || !viewerRole || !staffRole) {
|
if (!adminRole || !viewerRole || !staffRole || !officerRole) {
|
||||||
console.error('Roles not found. Please seed roles first.');
|
console.error('One or more roles not found. Please seed roles first.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -59,6 +66,62 @@ export class PermissionSeeder {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Officer permissions - operational access focused on their domain
|
||||||
|
for (const resource of allResources) {
|
||||||
|
// Define which resources officers can fully manage
|
||||||
|
const officerFullAccessResources = [
|
||||||
|
'incident_logs',
|
||||||
|
'evidence',
|
||||||
|
'locations',
|
||||||
|
'location_logs',
|
||||||
|
'patrol_units'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Define resources officers can view and update but not create/delete
|
||||||
|
const officerLimitedAccessResources = [
|
||||||
|
'crime_incidents',
|
||||||
|
'officers'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Define resources officers can only read
|
||||||
|
const officerReadOnlyResources = [
|
||||||
|
'crimes',
|
||||||
|
'crime_categories',
|
||||||
|
'units',
|
||||||
|
'districts',
|
||||||
|
'cities'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (officerFullAccessResources.includes(resource.name)) {
|
||||||
|
// Officers can fully manage operational resources
|
||||||
|
await this.createPermissions(officerRole.id, resource.id, [
|
||||||
|
'create',
|
||||||
|
'read',
|
||||||
|
'update',
|
||||||
|
'delete',
|
||||||
|
]);
|
||||||
|
} else if (officerLimitedAccessResources.includes(resource.name)) {
|
||||||
|
// Officers can read and update but not create/delete certain resources
|
||||||
|
await this.createPermissions(officerRole.id, resource.id, [
|
||||||
|
'read',
|
||||||
|
'update',
|
||||||
|
]);
|
||||||
|
} else if (officerReadOnlyResources.includes(resource.name)) {
|
||||||
|
// Officers can only read reference data
|
||||||
|
await this.createPermissions(officerRole.id, resource.id, ['read']);
|
||||||
|
} else if (['events', 'sessions'].includes(resource.name)) {
|
||||||
|
// Officers can create and manage events/sessions
|
||||||
|
await this.createPermissions(officerRole.id, resource.id, [
|
||||||
|
'create',
|
||||||
|
'read',
|
||||||
|
'update',
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
// For all other resources, officers get read-only access
|
||||||
|
await this.createPermissions(officerRole.id, resource.id, ['read']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Permissions seeded successfully!');
|
console.log('Permissions seeded successfully!');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error seeding permissions:', error);
|
console.error('Error seeding permissions:', error);
|
||||||
|
|
|
@ -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