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