diff --git a/README.md b/README.md
index 9639f94e..b8a504d4 100644
--- a/README.md
+++ b/README.md
@@ -1,56 +1,3 @@
-DONT FORGET TO BUILT IT
-THIS CHANGES NEEDS TO BE DONE:
-```lua
-CREATE TABLE IF NOT EXISTS mdt_patrols (
- id VARCHAR(64) PRIMARY KEY,
- name VARCHAR(64) NOT NULL,
- color VARCHAR(7) NOT NULL,
- zone_points LONGTEXT NULL DEFAULT NULL,
- sort_order INT NOT NULL DEFAULT 0,
- member_ids TEXT NOT NULL DEFAULT '[]'
-);
-
-CREATE TABLE IF NOT EXISTS `mdt_bulletin_posts` (
- `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
- `title` VARCHAR(255) NOT NULL,
- `content` LONGTEXT NOT NULL,
- `author` VARCHAR(100) NOT NULL,
- `author_rank` VARCHAR(100) NOT NULL DEFAULT '',
- `category` VARCHAR(48) NOT NULL DEFAULT 'general',
- `priority` ENUM(
- 'low',
- 'normal',
- 'high',
- 'urgent'
- ) NOT NULL DEFAULT 'normal',
- `pinned` TINYINT(1) NOT NULL DEFAULT 0,
- `job` VARCHAR(50) NOT NULL DEFAULT '',
- `created_by` VARCHAR(60) NOT NULL,
- `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- `updated_at` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
- `deleted_at` DATETIME NULL DEFAULT NULL,
- PRIMARY KEY (`id`),
- KEY `idx_job` (`job`),
- KEY `idx_category` (`category`),
- KEY `idx_pinned` (`pinned`),
- KEY `idx_deleted_at` (`deleted_at`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-
-CREATE TABLE IF NOT EXISTS `mdt_bulletin_categories` (
- `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
- `job` VARCHAR(50) NOT NULL,
- `value` VARCHAR(48) NOT NULL,
- `label` VARCHAR(48) NOT NULL,
- `icon` VARCHAR(48) NOT NULL DEFAULT 'label',
- `color` VARCHAR(7) NOT NULL DEFAULT '#6B7280',
- `sort_order` SMALLINT NOT NULL DEFAULT 0,
- `is_default` TINYINT(1) NOT NULL DEFAULT 0,
- PRIMARY KEY (`id`),
- UNIQUE KEY `uq_job_value` (`job`, `value`),
- INDEX `idx_job_order` (`job`, `sort_order`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=UTF8MB4_UNICODE_CI;
-```
-
# ps-mdt v3
Police MDT (Mobile Data Terminal) for FiveM. Built with Svelte 5 and Lua. Works on QBCore and QBX through the ps_lib abstraction layer.
@@ -358,4 +305,4 @@ We host some of the biggest FiveM servers in the industry such as Prodigy RP, Sm
- Free transfer of files and setup
- Free Windows licenses
- Windows Remote Desktop
-- 24/7 Support with ~30 min average ticket response
+- 24/7 Support with ~30 min average ticket response
\ No newline at end of file
diff --git a/client/animation.lua b/client/animation.lua
index 0ac64766..e68dd589 100644
--- a/client/animation.lua
+++ b/client/animation.lua
@@ -1,7 +1,7 @@
local tabletProp = nil
local isPlayingTabletAnim = false
-local animDict = Config.Animation and Config.Animation.Dict or 'amb@code_human_in_bus_passenger_idles@female@tablet@idle_a'
-local animName = Config.Animation and Config.Animation.Name or 'idle_a'
+local animDict = Config.Animation and Config.Animation.Dict or 'amb@world_human_tourist_map@male@base'
+local animName = Config.Animation and Config.Animation.Name or 'base'
local propModel = 'ps-mdt'
-- Helper to always get current ped (never stale)
@@ -12,10 +12,10 @@ end
-- Options ----------------------------------
local propOptions = {
- xPos = -0.050, -- X-axis offset from the center of entity2
- yPos = 0.0, -- Y-axis offset from the center of entity2
+ xPos = 0.0, -- X-axis offset from the center of entity2
+ yPos = -0.03, -- Y-axis offset from the center of entity2
zPos = 0.0, -- Z-axis offset from the center of entity2
- xRot = 0.0, -- X-axis rotation
+ xRot = 20.0, -- X-axis rotation
yRot = -90.0, -- Y-axis rotation
zRot = 0.0, -- Z-axis rotation
p9 = true, -- Unknown
diff --git a/client/backend/bodycams.lua b/client/backend/bodycams.lua
index aaeac0ed..f10fa0d0 100644
--- a/client/backend/bodycams.lua
+++ b/client/backend/bodycams.lua
@@ -22,7 +22,7 @@ RegisterNUICallback('viewBodycam', function(data, cb)
local result = ps.callback(resourceName .. ':server:viewBodycam', bodycamId)
if result and result.success then
- CloseMDT(true)
+ CloseMDT()
cb({ success = true })
else
cb({ success = false, message = result and result.error or 'Failed to view bodycam' })
diff --git a/client/backend/bulletinboard.lua b/client/backend/bulletinboard.lua
deleted file mode 100644
index bbd989e8..00000000
--- a/client/backend/bulletinboard.lua
+++ /dev/null
@@ -1,86 +0,0 @@
-local resourceName = tostring(GetCurrentResourceName())
-
--- Get all bulletin posts for the officer's department
-RegisterNUICallback('getBulletinPosts', function(data, cb)
- local result = ps.callback(resourceName .. ':server:getBulletinPosts')
- cb(result or {})
-end)
-
--- Create a new bulletin post
-RegisterNUICallback('createBulletinPost', function(data, cb)
- if not data then cb({ success = false }) return end
- local result = ps.callback(resourceName .. ':server:createBulletinPost', data)
- cb(result or { success = false })
-end)
-
--- Update an existing bulletin post
-RegisterNUICallback('updateBulletinPost', function(data, cb)
- if not data or not data.id then cb({ success = false }) return end
- local postId = data.id
- data.id = nil
- local result = ps.callback(resourceName .. ':server:updateBulletinPost', postId, data)
- cb(result or { success = false })
-end)
-
--- Delete a bulletin post
-RegisterNUICallback('deleteBulletinPost', function(data, cb)
- if not data or not data.id then cb({ success = false }) return end
- local result = ps.callback(resourceName .. ':server:deleteBulletinPost', data.id)
- cb(result or { success = false })
-end)
-
--- Toggle pin on a bulletin post (supervisor only)
-RegisterNUICallback('toggleBulletinPin', function(data, cb)
- if not data or not data.id then cb({ success = false }) return end
- local result = ps.callback(resourceName .. ':server:toggleBulletinPin', data.id)
- cb(result or { success = false })
-end)
-
--- ── Category management ───────────────────────────────────────
-
--- Get all categories for the current job
-RegisterNUICallback('getBulletinCategories', function(data, cb)
- local result = ps.callback(resourceName .. ':server:getBulletinCategories')
- cb(result or {})
-end)
-
--- Add a new category
-RegisterNUICallback('addBulletinCategory', function(data, cb)
- if not data or not data.label or data.label == '' then
- cb({ success = false, error = 'Missing required fields' })
- return
- end
- local result = ps.callback(resourceName .. ':server:addBulletinCategory', data)
- cb(result or { success = false })
-end)
-
--- Update an existing category (label, icon, color, sort_order)
-RegisterNUICallback('updateBulletinCategory', function(data, cb)
- if not data or not data.value then
- cb({ success = false, error = 'Missing category value' })
- return
- end
- local result = ps.callback(resourceName .. ':server:updateBulletinCategory', data)
- cb(result or { success = false })
-end)
-
--- Remove a category by value
-RegisterNUICallback('removeBulletinCategory', function(data, cb)
- if not data or not data.value then
- cb({ success = false, error = 'Missing category value' })
- return
- end
- local result = ps.callback(resourceName .. ':server:removeBulletinCategory', data)
- cb(result or { success = false })
-end)
-
--- Reorder categories (bulk sort_order update)
-RegisterNUICallback('reorderBulletinCategories', function(data, cb)
- if not data or not data.order then
- cb({ success = false, error = 'Missing order data' })
- return
- end
- -- Pass the whole data table so server can read data.order reliably
- local result = ps.callback(resourceName .. ':server:reorderBulletinCategories', data)
- cb(result or { success = false })
-end)
\ No newline at end of file
diff --git a/client/backend/cameras.lua b/client/backend/cameras.lua
index 57566958..2c1ac1a1 100644
--- a/client/backend/cameras.lua
+++ b/client/backend/cameras.lua
@@ -22,7 +22,7 @@ RegisterNUICallback('viewCamera', function(data, cb)
local result = ps.callback(resourceName .. ':server:viewCamera', cameraId)
if result and result.success then
- CloseMDT(true)
+ CloseMDT()
cb({ success = true })
else
cb({ success = false, message = result and result.error or 'Failed to view camera' })
diff --git a/client/backend/cases.lua b/client/backend/cases.lua
index 5f27c5ee..3629ce2b 100644
--- a/client/backend/cases.lua
+++ b/client/backend/cases.lua
@@ -190,22 +190,12 @@ RegisterNUICallback('addEvidenceItem', function(data, cb)
return
end
- local payload = data or {}
-
- if payload.evidence then
- local result = ps.callback(
- resourceName .. ':server:addEvidenceItem',
- payload
- )
- cb(result or { success = false })
- else
- local result = ps.callback(
- resourceName .. ':server:addEvidenceItem',
- data.caseId,
- data.evidence
- )
- cb(result or { success = false })
- end
+ local result = ps.callback(
+ resourceName .. ':server:addEvidenceItem',
+ data.caseId,
+ data.evidence
+ )
+ cb(result or { success = false })
end)
RegisterNUICallback('updateEvidenceItem', function(data, cb)
diff --git a/client/backend/citizens.lua b/client/backend/citizens.lua
index 703ad80a..1b83edb0 100644
--- a/client/backend/citizens.lua
+++ b/client/backend/citizens.lua
@@ -6,38 +6,6 @@ RegisterNUICallback('getMyProfile', function(data, cb)
cb(result or { success = false })
end)
-RegisterNUICallback('getProperty', function(data, cb)
- if not MDTOpen then cb({ success = false }) return end
- if not data or not data.property_id then
- cb({ success = false, message = 'Missing property id' })
- return
- end
- local result = ps.callback(resourceName .. ':server:getProperty', data.property_id)
- if not result or not result.success then
- cb(result or { success = false, message = 'Property not found' })
- return
- end
- if result.property and result.property.coords then
- local coords = result.property.coords
- local street1, street2 = GetStreetNameAtCoord(coords.x, coords.y, coords.z, 1.0)
- local s1 = street1 and GetStreetNameFromHashKey(street1) or nil
- local s2 = street2 and GetStreetNameFromHashKey(street2) or nil
- if s1 and s1 ~= '' then
- result.property.streetName = (s2 and s2 ~= '') and (s1 .. ' / ' .. s2) or s1
- end
- end
- cb(result)
-end)
-
--- setWaypoint: sets a GPS blip on the map from NUI coords
--- The NUI calls fetchNui('setWaypoint', { x, y }) — no server round-trip needed.
-RegisterNUICallback('setWaypoint', function(data, cb)
- cb({})
- if not data or not data.x or not data.y then return end
- SetNewWaypoint(data.x, data.y)
-end)
-
-
RegisterNUICallback('getCitizens', function(data, cb)
if not MDTOpen then cb({}) return end
if type(data) ~= 'table' then
diff --git a/client/backend/collab.lua b/client/backend/collab.lua
index 8ac26655..724f1a41 100644
--- a/client/backend/collab.lua
+++ b/client/backend/collab.lua
@@ -27,31 +27,6 @@ RegisterNUICallback('syncReportData', function(data, cb)
TriggerServerEvent(resourceName .. ':server:collabSyncData', data.reportId, data.dataType, data.data)
end)
-local awarenessIncomingBuffer = {}
-
-RegisterNUICallback('syncAwareness', function(data, cb)
- TriggerServerEvent(resourceName .. ':server:collabSyncAwareness', data.reportId, data.update)
- cb({ ok = true })
-end)
-
-RegisterNetEvent(resourceName .. ':client:awarenessBatch')
-AddEventHandler(resourceName .. ':client:awarenessBatch', function(payload)
- if not payload or not payload.updates then return end
- for _, u in ipairs(payload.updates) do
- awarenessIncomingBuffer[#awarenessIncomingBuffer + 1] = u
- end
-end)
-
-RegisterNUICallback('pollAwareness', function(data, cb)
- if #awarenessIncomingBuffer == 0 then
- cb({ updates = {} })
- return
- end
- local batch = awarenessIncomingBuffer
- awarenessIncomingBuffer = {}
- cb({ updates = batch })
-end)
-
-- Server push events -> forward to NUI
RegisterNetEvent(resourceName .. ':client:reportEditorJoined', function(data)
SendNUI('reportEditorJoined', data)
diff --git a/client/backend/dashboard.lua b/client/backend/dashboard.lua
index e3319fc5..a56db50b 100644
--- a/client/backend/dashboard.lua
+++ b/client/backend/dashboard.lua
@@ -60,9 +60,6 @@ function NUIUpdateAuth()
citizenid = playerData.citizenid,
job = playerData.job,
charinfo = playerData.charinfo,
- metadata = type(playerData.metadata) == 'table' and {
- callsign = playerData.metadata.callsign or '',
- } or nil,
} or nil,
isLEO = isAuthorized,
onDuty = ps.getJobDuty() or false,
@@ -177,32 +174,6 @@ RegisterNUICallback('deleteBulletin', function(data, cb)
cb(result or { success = false })
end)
-RegisterNUICallback('getBulletinCategories', function(_, cb)
- local result = ps.callback(resourceName .. ':server:getBulletinCategories', false)
- cb(result or {})
-end)
-
-RegisterNUICallback('saveBulletinCategories', function(data, cb)
- if not data or not data.categories then
- cb({ success = false, message = 'Invalid data' })
- return
- end
-
- for _, cat in ipairs(data.categories) do
- if type(cat.value) ~= 'string' or type(cat.label) ~= 'string' or type(cat.icon) ~= 'string' then
- cb({ success = false, message = 'Malformed category entry' })
- return
- end
- if #cat.label > 32 or #cat.icon > 48 then
- cb({ success = false, message = 'Category label or icon name too long' })
- return
- end
- end
-
- local result = ps.callback('mdt:server:saveBulletinCategories', false, data.categories)
- cb(result or { success = false, message = 'Server error' })
-end)
-
-- RECENT REPORTS -------------------------------------
RegisterNUICallback('getRecentReports', function(data, cb)
diff --git a/client/backend/officers.lua b/client/backend/officers.lua
index 16475bb7..7fb7a99f 100644
--- a/client/backend/officers.lua
+++ b/client/backend/officers.lua
@@ -6,10 +6,12 @@ RegisterNUICallback('setCallsign', function(data, cb)
cb({ success = false, message = 'MDT is not open' })
return
end
+
if type(data) ~= 'table' or (not data.cid and not data.citizenid) or (not data.newcallsign and not data.callsign) then
cb({ success = false, message = 'Missing citizen ID or callsign' })
return
end
+
local result = ps.callback(resourceName .. ':server:setCallsign', {
citizenid = data.cid or data.citizenid,
callsign = data.newcallsign or data.callsign,
@@ -17,14 +19,6 @@ RegisterNUICallback('setCallsign', function(data, cb)
cb(result or { success = false, message = 'Failed to set callsign' })
end)
-RegisterNUICallback('getCallsign', function(data, cb)
- if not MDTOpen then cb({ callsign = '' }) return end
- local result = ps.callback(resourceName .. ':server:getCallsign', {
- citizenid = data.citizenid,
- })
- cb(result or { callsign = '' })
-end)
-
-- Set Radio Frequency
RegisterNUICallback('setRadio', function(data, cb)
if not MDTOpen then
diff --git a/client/backend/reports.lua b/client/backend/reports.lua
index 0918e2d5..85306895 100644
--- a/client/backend/reports.lua
+++ b/client/backend/reports.lua
@@ -83,8 +83,8 @@ RegisterNUICallback('saveReport', function(data, cb)
if victim.citizenid then
table.insert(involved, {
citizenid = victim.citizenid,
- type = victim.type or 'victim',
- notes = victim.notes or ''
+ type = 'victim',
+ notes = ''
})
end
end
@@ -106,12 +106,9 @@ RegisterNUICallback('saveReport', function(data, cb)
if data.evidence then
for _, item in ipairs(data.evidence) do
table.insert(evidence, {
- title = item.title or '',
type = item.type or 'Evidence',
- content = item.serial or '',
- note = item.notes or '',
- stored = item.stored or 0,
- images = item.images or {}
+ content = item.serial or (item.images and item.images[1]) or item.title or '',
+ note = item.notes or ''
})
end
end
@@ -315,11 +312,6 @@ RegisterNUICallback('searchVehiclesForReport', function(data, cb)
end
local query = data and data.query or ''
- if query == '' then
- cb({})
- return
- end
-
local result = ps.callback(resourceName .. ':server:searchVehiclesForReport', query)
cb(result or {})
-end)
\ No newline at end of file
+end)
diff --git a/client/backend/tracking.lua b/client/backend/tracking.lua
index 06407428..ce59dc51 100644
--- a/client/backend/tracking.lua
+++ b/client/backend/tracking.lua
@@ -1,376 +1,15 @@
--- ----------------------------------------------------------------------------
--- Responsibilities:
--- * Forwards patrol/tracking data between server and the map NUI.
--- * Maintains one ox_lib PolyZone per patrol and fires enter/exit
--- notifications, but ONLY for the patrol the local player belongs to.
--- * Zones live independently of the MDT: they are built on spawn and on
--- resource start, so notifications work without ever opening the MDT.
---
--- Performance notes for future devs:
--- * syncPatrols is broadcast to ALL clients on EVERY patrol mutation
--- (create/delete/assign/remove/rename/reorder/zone). syncZones() therefore
--- diffs each zone's geometry signature and only rebuilds zones whose points
--- actually changed — a rename/reorder/assign no longer tears down and
--- recreates every PolyZone on every client.
--- * Patrol names shown in zone notifications are read live from
--- zonePatrolNames, so a rename never forces a zone rebuild.
---
--- Set MDT_DEBUG = true for verbose console logging while developing.
--- ============================================================================
-
local resourceName = tostring(GetCurrentResourceName())
--- ─── Tunables ───────────────────────────────────────────────────────────────
-local MDT_DEBUG = false -- verbose dev logging; KEEP FALSE on production
-
-local function dbg(...)
- if MDT_DEBUG then print('[MDT]', ...) end
-end
-
--- UI state held in Lua client (survives MDT open/close)
-local mapUiState = {
- sidebarOpen = true,
- officersOpen = true,
- patrolsOpen = true,
-}
-
--- ─── Patrol zone notification preference ─────────────────────────────────────
--- Read from localStorage via a NUI message on resource start.
--- Defaults to true (enabled) until the NUI reports otherwise.
-local patrolZoneNotificationsEnabled = true
-
-AddEventHandler('onClientResourceStart', function(res)
- if res ~= resourceName then return end
- -- Ask the NUI to send us the stored preference
- SendNUIMessage({ type = 'requestPatrolZonePref' })
-end)
-
--- The NUI page reads localStorage and sends this back
-RegisterNUICallback('patrolZonePref', function(data, cb)
- if type(data.enabled) == 'boolean' then
- patrolZoneNotificationsEnabled = data.enabled
- end
- cb({})
-end)
-
--- ─── Notification helper (works with QBCore, ox_lib, ps-ui) ─────────────────
-
-local function mdtNotify(title, description, notifyType, duration)
- -- Respect the player's preference stored in localStorage via NUI
- if not patrolZoneNotificationsEnabled then return end
- duration = duration or 4000
- if exports['ox_lib'] then
- lib.notify({ title = title, description = description, type = notifyType, duration = duration })
- elseif exports['qb-core'] then
- exports['qb-core']:GetCoreObject().Functions.Notify(('[%s] %s'):format(title, description), notifyType, duration)
- elseif exports['ps-ui'] then
- exports['ps-ui']:Notify({ text = ('[%s] %s'):format(title, description), type = notifyType, length = duration })
- else
- BeginTextCommandThefeedPost('STRING')
- AddTextComponentSubstringPlayerName(('[%s] %s'):format(title, description))
- EndTextCommandThefeedPostTick()
- end
-end
-
--- ─── Patrol Zone tracking ──────────────────────────────────────────────────
--- Keeps one PolyZone per patrol id. Zones are (re)built only when their
--- geometry changes (see syncZones).
-
-local activeZones = {} -- [patrolId] = ox_lib PolyZone object
-local zoneSignatures = {} -- [patrolId] = geometry signature (string)
-local zonePatrolNames = {} -- [patrolId] = latest patrol name (read live by callbacks)
-local myPatrolId = nil -- patrol the local player currently belongs to
-
-local function getCitizenId()
- if exports['qb-core'] then
- return exports['qb-core']:GetCoreObject().Functions.GetPlayerData().citizenid
- elseif ps and ps.getIdentifier then
- return ps.getIdentifier()
- end
- return nil
-end
-
-local function destroyZone(patrolId)
- if activeZones[patrolId] then
- activeZones[patrolId]:remove()
- activeZones[patrolId] = nil
- end
-end
-
-local function destroyAllZones()
- for id in pairs(activeZones) do
- destroyZone(id)
- end
- zoneSignatures = {}
- zonePatrolNames = {}
-end
-
--- A cheap, stable string fingerprint of a zone's points. Identical geometry =>
--- identical signature => no rebuild needed.
-local function pointsSignature(points)
- if not points then return "" end
- local parts = {}
- for i = 1, #points do
- parts[i] = string.format("%.1f,%.1f", points[i].x or 0.0, points[i].y or 0.0)
- end
- return table.concat(parts, ";")
-end
-
--- Convert GTA world {x,y} array → ox_lib poly-zone points (vector3; z required,
--- but ignored by lib.zones.poly).
-local function toVector3List(points)
- local vecs = {}
- for i = 1, #points do
- vecs[i] = vector3(points[i].x, points[i].y, 0.0)
- end
- return vecs
-end
-
-local function createZoneForPatrol(patrol)
- destroyZone(patrol.id)
-
- local points = patrol.zonePoints
- if not points or #points < 3 then return end
-
- local vecs = toVector3List(points)
- local pid = patrol.id
-
- -- Suppress the initial onEnter that fires when the zone is first created
- -- (happens on spawn, MDT open, resource restart)
- local suppressInitial = true
- SetTimeout(2500, function() suppressInitial = false end)
-
- local zone = lib.zones.poly({
- points = vecs,
- thickness = 1600,
- debug = false,
- onEnter = function()
- if pid ~= myPatrolId then return end
- if suppressInitial then return end
- mdtNotify(zonePatrolNames[pid] or 'Patrol', 'entered Zone', 'success', 3000)
- end,
- onExit = function()
- if pid ~= myPatrolId then return end
- mdtNotify(zonePatrolNames[pid] or 'Patrol', 'left Zone', 'error', 5000)
- end,
- })
-
- activeZones[pid] = zone
-end
-
--- Rebuild only the zones whose geometry changed, and figure out which patrol the
--- local player is in. Cheap to call on every syncPatrols broadcast.
-local function syncZones(patrols)
- local citizenId = getCitizenId()
- myPatrolId = nil
-
- -- Destroy zones for patrols that no longer exist
- local incoming = {}
- for _, p in ipairs(patrols) do incoming[p.id] = true end
- for id in pairs(activeZones) do
- if not incoming[id] then
- destroyZone(id)
- zoneSignatures[id] = nil
- zonePatrolNames[id] = nil
- end
- end
-
- for _, patrol in ipairs(patrols) do
- -- Keep the name fresh so renames update notifications without a rebuild
- zonePatrolNames[patrol.id] = patrol.name
-
- -- Resolve local membership
- if citizenId then
- for _, mid in ipairs(patrol.memberIds) do
- if mid == citizenId then
- myPatrolId = patrol.id
- break
- end
- end
- end
-
- local hasZone = patrol.zonePoints and #patrol.zonePoints >= 3
- if hasZone then
- local sig = pointsSignature(patrol.zonePoints)
- if zoneSignatures[patrol.id] ~= sig then
- createZoneForPatrol(patrol)
- zoneSignatures[patrol.id] = sig
- dbg(('rebuilt zone for "%s" (%d pts)'):format(patrol.name, #patrol.zonePoints))
- end
- else
- if activeZones[patrol.id] then destroyZone(patrol.id) end
- zoneSignatures[patrol.id] = nil
- end
- end
-end
-
--- ─── Server → NUI ─────────────────────────────────────────────────────────
-
-RegisterNetEvent(resourceName .. ":client:syncPatrols", function(patrols, action, citizenid)
- SendNUIMessage({ type = "syncPatrols", data = patrols, action = action, citizenid = citizenid })
-
- -- Rebuild PolyZones whenever patrol data changes (diffed inside syncZones)
- if type(patrols) == "table" then
- syncZones(patrols)
- end
-end)
-
--- Called from client.lua after setVisible(true) — send citizenId so map centers on self
-function SendMapCitizenId()
- local cid = getCitizenId()
- if cid then
- SendNUIMessage({ type = 'setLocalCitizenId', data = { citizenid = cid } })
- end
-end
-
-RegisterNetEvent(resourceName .. ':client:checkVehicleClass', function(netId, plate, coords, heading)
- local veh = NetworkGetEntityFromNetworkId(netId)
- if not veh or veh == 0 then return end
- if GetVehicleClass(veh) ~= 18 then return end -- 18 = Emergency
- TriggerServerEvent(resourceName .. ':server:cacheVehicle', plate, coords, heading)
-end)
-
--- ─── UI State ─────────────────────────────────────────────────────────────
-
-function SendMapUiState()
- SendNUIMessage({ type = "mapUiState", data = mapUiState })
-end
-
-RegisterNUICallback("saveMapUiState", function(data, cb)
- if type(data.key) == "string" and type(data.value) == "boolean" then
- mapUiState[data.key] = data.value
- end
- cb({})
-end)
-
--- ─── Tracking ─────────────────────────────────────────────────────────────
-
-RegisterNUICallback("getTracking", function(_, cb)
+RegisterNUICallback('getTracking', function(_, cb)
if not MDTOpen then
- cb({ success = false, data = { vehicles = {}, bodycams = {} } })
+ cb({ success = false, message = 'MDT is not open', data = {} })
return
end
- local tracking = ps.callback(resourceName .. ":server:getTracking")
+ local tracking = ps.callback(resourceName .. ':server:getTracking')
if tracking then
cb({ success = true, data = tracking })
else
- cb({ success = false, data = { vehicles = {}, bodycams = {} } })
- end
-end)
-
--- ─── Patrols ──────────────────────────────────────────────────────────────
-
-RegisterNUICallback("getPatrols", function(_, cb)
- local result = ps.callback(resourceName .. ":server:getPatrols")
- local patrols = result or {}
- cb({ success = true, data = patrols })
- -- Also sync zones immediately on load
- if type(patrols) == "table" then
- syncZones(patrols)
- end
-end)
-
-RegisterNUICallback("createPatrol", function(data, cb)
- if not MDTOpen then cb({ success = false }) return end
- local id, name, color = data.id, data.name, data.color
- if type(id) ~= "string" or type(name) ~= "string" or type(color) ~= "string" then
- cb({ success = false }) return
- end
- TriggerServerEvent(resourceName .. ":server:createPatrol", id, name, color)
- cb({ success = true })
-end)
-
-RegisterNUICallback("deletePatrol", function(data, cb)
- if not MDTOpen then cb({ success = false }) return end
- if type(data.id) ~= "string" then cb({ success = false }) return end
- TriggerServerEvent(resourceName .. ":server:deletePatrol", data.id)
- cb({ success = true })
-end)
-
-RegisterNUICallback("renamePatrol", function(data, cb)
- if not MDTOpen then cb({ success = false }) return end
- if type(data.id) ~= "string" or type(data.name) ~= "string" then
- cb({ success = false }) return
+ cb({ success = false, message = 'Failed to fetch tracking data', data = {} })
end
- TriggerServerEvent(resourceName .. ":server:renamePatrol", data.id, data.name)
- cb({ success = true })
end)
-
-RegisterNUICallback("assignOfficer", function(data, cb)
- if not MDTOpen then cb({ success = false }) return end
- if type(data.patrolId) ~= "string" or type(data.citizenId) ~= "string" then
- cb({ success = false }) return
- end
- TriggerServerEvent(resourceName .. ":server:assignOfficer", data.patrolId, data.citizenId)
- cb({ success = true })
-end)
-
-RegisterNUICallback("reorderPatrols", function(data, cb)
- if not MDTOpen then cb({ success = false }) return end
- if type(data.ids) ~= "table" then cb({ success = false }) return end
- TriggerServerEvent(resourceName .. ":server:reorderPatrols", data.ids)
- cb({ success = true })
-end)
-
-RegisterNUICallback("removeFromPatrol", function(data, cb)
- if not MDTOpen then cb({ success = false }) return end
- if type(data.citizenId) ~= "string" then cb({ success = false }) return end
- TriggerServerEvent(resourceName .. ":server:removeFromPatrol", data.citizenId)
- cb({ success = true })
-end)
-
--- ─── Cleanup ──────────────────────────────────────────────────────────────
-
-AddEventHandler("onResourceStop", function(res)
- if res ~= resourceName then return end
- destroyAllZones()
-end)
-
--- ─── Auto-init zones on spawn (independent of MDT) ────────────────────────
--- Loads patrol zone data from the server as soon as the player spawns, so zone
--- entry/exit notifications work without ever opening the MDT.
-
-local function initZonesFromServer()
- -- Send citizenId so the map can center on the officer's own position
- local cid = getCitizenId()
- if cid then
- SendNUIMessage({ type = 'setLocalCitizenId', data = { citizenid = cid } })
- end
-
- local result = ps.callback(resourceName .. ":server:getPatrols")
- if type(result) == "table" then
- syncZones(result)
- end
-end
-
--- Fires when the player first spawns
-RegisterNetEvent('QBCore:Client:OnPlayerLoaded', function()
- -- Small delay so the framework has finished setting up the player
- SetTimeout(2000, initZonesFromServer)
-end)
-
--- ─── Zone Drawing ─────────────────────────────────────────────────────────
--- Called by the Svelte map after the user finishes drawing a zone polygon.
--- data.id = patrol id (string)
--- data.points = array of { x, y } in GTA world coordinates, or nil to clear
-RegisterNUICallback("setPatrolZone", function(data, cb)
- if not MDTOpen then cb({ success = false }) return end
- if type(data.id) ~= "string" then cb({ success = false }) return end
-
- -- Validate points on client before sending to server
- local points = data.points
- if points ~= nil then
- if type(points) ~= "table" or #points < 3 then
- cb({ success = false, error = "need_3_points" }) return
- end
- for _, pt in ipairs(points) do
- if type(pt) ~= "table" or type(pt.x) ~= "number" or type(pt.y) ~= "number" then
- cb({ success = false, error = "invalid_point" }) return
- end
- end
- end
-
- TriggerServerEvent(resourceName .. ":server:setPatrolZone", data.id, points)
- cb({ success = true })
-end)
\ No newline at end of file
diff --git a/client/backend/weapons.lua b/client/backend/weapons.lua
index 79532dae..fb7fa3c4 100644
--- a/client/backend/weapons.lua
+++ b/client/backend/weapons.lua
@@ -14,12 +14,6 @@ RegisterNUICallback('getWeaponBolos', function(data, cb)
cb(result)
end)
-RegisterNUICallback('saveWeaponFlags', function(data, cb)
- local flags = type(data.flags) == 'table' and data.flags or json.decode(data.flags) or {}
- local result = ps.callback(resourceName .. ':server:saveWeaponFlags', data.serial, flags)
- cb(result or { success = false })
-end)
-
RegisterNUICallback('getWeaponOwnershipHistory', function(data, cb)
if not MDTOpen then cb({}) return end
if not data or not data.serial then
diff --git a/client/cameras.lua b/client/cameras.lua
index f0b5b85d..3db4fd91 100644
--- a/client/cameras.lua
+++ b/client/cameras.lua
@@ -50,7 +50,6 @@ local function stopCameraView(notifyServer)
ClearTimecycleModifier()
ps.debug('Clearing focus area')
- StopTabletAnimation()
ClearFocus()
DoScreenFadeIn(250)
@@ -196,19 +195,22 @@ updateCameraControls = function()
if currentCameraData and currentCameraData.isBodycam and currentCameraData.targetSource then
local targetPed = GetPlayerPed(GetPlayerFromServerId(currentCameraData.targetSource))
if targetPed and targetPed ~= 0 and DoesEntityExist(targetPed) then
+ -- SKEL_Head bone index = 31086
+ local boneIndex = GetPedBoneIndex(targetPed, 31086)
+ local boneCoords = GetPedBoneCoords(targetPed, boneIndex, 0.0, 0.0, 0.0)
+ -- Offset slightly forward and up from the head to simulate chest/shoulder bodycam
local forward = GetEntityForwardVector(targetPed)
- local pedCoords = GetEntityCoords(targetPed)
-
- local camX = pedCoords.x + forward.x * 0.3
- local camY = pedCoords.y + forward.y * 0.3
- local camZ = pedCoords.z + 0.4
-
+ local camX = boneCoords.x + forward.x * 0.1
+ local camY = boneCoords.y + forward.y * 0.1
+ local camZ = boneCoords.z + 0.05
SetCamCoord(currentCamera, camX, camY, camZ)
+ -- Point camera in the direction the ped is facing
local heading = GetEntityHeading(targetPed)
- local camZ_rot = heading
+ local currentRot = GetCamRot(currentCamera, 2)
+ SetCamRot(currentCamera, currentRot.x, currentRot.y, -heading, 2)
- SetCamRot(currentCamera, -10.0, 0.0, camZ_rot, 2)
+ -- Update focus area so world streams around the target
SetFocusPosAndVel(camX, camY, camZ, 0, 0, 0)
end
end
diff --git a/client/keys.lua b/client/keys.lua
index 3f2f44f9..14f7677a 100644
--- a/client/keys.lua
+++ b/client/keys.lua
@@ -147,9 +147,7 @@ function OpenMDT()
MDTOpen = true
SendNUI('setVisible', { visible = true, debugMode = Config.Debug })
- SendMapCitizenId()
- SendMapUiState()
-
+
if isCivilian then
-- Civilian mode: send auth with civilian flag
local playerData = ps.getPlayerData()
@@ -176,14 +174,11 @@ end
-- Close MDT
local closeControlsPending = false
-
-function CloseMDT(keepAnimation)
+function CloseMDT()
if MDTOpen then
MDTOpen = false
- if not keepAnimation then
- StopTabletAnimation()
- end
+ StopTabletAnimation()
SendNUI('setVisible', { visible = false })
SetNuiFocus(false, false)
diff --git a/config.lua b/config.lua
index a3b10f62..9978e0a8 100644
--- a/config.lua
+++ b/config.lua
@@ -17,7 +17,7 @@ Config.CivilianAccess = {
Config.DateTime = {
GameTime = true, -- If set to true, the game time will be used instead of the server time (boolean)
TimeFormat = '24', -- Format for displaying time ('24' or '12')
- DateFormat = "DD-MM-YYYY" -- Format for displaying date (string: "MM-DD-YYYY", "DD-MM-YYYY", or "YYYY-MM-DD")
+ DateFormat = "MM-DD-YYYY" -- Format for displaying date (string: "MM-DD-YYYY", "DD-MM-YYYY", or "YYYY-MM-DD")
}
-- Department data sharing
@@ -100,7 +100,7 @@ Config.FingerprintAutoFilled = false -- Auto-populate fingerprints on citizen pr
-- Fingerprint Scan Integration
Config.FingerprintScan = {
- enabled = true, -- Enable fingerprint scan trigger from MDT
+ enabled = false, -- Enable fingerprint scan trigger from MDT
officerEvent = 'police:client:showFingerprint', -- Client event triggered on the officer
suspectEvent = 'police:client:showFingerprint', -- Client event triggered on the suspect
}
@@ -182,8 +182,8 @@ Config.CacheTTL = {
-- Tablet Animation
Config.Animation = {
- Dict = 'amb@code_human_in_bus_passenger_idles@female@tablet@idle_a',
- Name = 'idle_a',
+ Dict = 'amb@world_human_tourist_map@male@base',
+ Name = 'base',
}
-- Mugshot Camera
@@ -218,7 +218,6 @@ Config.ManagementPermissions = {
'vehicles_edit_dmv',
-- Weapons
'weapons_search',
- 'weapons_add',
-- Cases
'cases_view',
'cases_create',
@@ -241,9 +240,6 @@ Config.ManagementPermissions = {
'charges_view',
'charges_edit',
-- Dispatch
- 'map_patrols_view',
- "map_patrols_manage",
- "map_patrols_edit",
'dispatch_attach',
'dispatch_route',
-- Cameras & Bodycams
@@ -260,10 +256,6 @@ Config.ManagementPermissions = {
-- FTO
'fto_view',
'fto_manage',
- -- BulletIn Board
- 'bulletin_view',
- 'bulletin_post',
- 'bulletin_pin',
-- Management
'management_permissions',
'management_bulletins',
@@ -341,13 +333,3 @@ Config.CameraModels = {
['cctv_cam_08'] = 'p_cctv_s',
['cctv_cam_09'] = 'hei_prop_bank_cctv_02',
}
-
--- Which Weapons should be allowed to be registered manually
-Config.Weapons = {
- { model = "weapon_heavypistol", label = "Heavy Pistol" },
- { model = "weapon_sniperrifle", label = "Hunting Rifle" },
- { model = "weapon_ceramicpistol", label = "Ceramic Pistol" },
- { model = "weapon_doubleaction", label = "Double-Action Revolver" },
- { model = "weapon_navyrevolver", label = "Navy Revolver" },
- { model = "weapon_musket", label = "Musket" },
-}
\ No newline at end of file
diff --git a/server/backend/bulletinboard.lua b/server/backend/bulletinboard.lua
deleted file mode 100644
index 2d7817ac..00000000
--- a/server/backend/bulletinboard.lua
+++ /dev/null
@@ -1,502 +0,0 @@
-local resourceName = tostring(GetCurrentResourceName())
-
--- ════════════════════════════════════════════════════════════
--- Bulletin Posts
--- ════════════════════════════════════════════════════════════
-
--- ── Get all bulletin posts for the officer's department ──────
-
-ps.registerCallback(resourceName .. ':server:getBulletinPosts', function(source)
- local src = source
- if not CheckAuth(src) then return {} end
-
- local jobName = ps.getJobName(src)
- if not jobName or jobName == '' then return {} end
-
- local ok, posts = pcall(MySQL.query.await, [[
- SELECT
- bp.id, bp.title, bp.content, bp.author, bp.author_rank,
- bp.category, bp.priority, bp.pinned, bp.created_by,
- bp.created_at, bp.updated_at,
- COALESCE(bc.label, bp.category) AS category_label,
- COALESCE(bc.color, '#6B7280') AS category_color
- FROM mdt_bulletin_posts bp
- LEFT JOIN mdt_bulletin_categories bc
- ON bc.value = bp.category AND bc.job = bp.job
- WHERE bp.job = ?
- ORDER BY bp.pinned DESC,
- FIELD(bp.priority, 'urgent', 'high', 'normal', 'low'),
- bp.created_at DESC
- ]], { jobName })
-
- if not ok or not posts then return {} end
-
- for _, post in ipairs(posts) do
- post.pinned = post.pinned == 1 or post.pinned == '1' or post.pinned == true
- end
-
- return posts
-end)
-
--- ── Create a bulletin post ────────────────────────────────────
-
-ps.registerCallback(resourceName .. ':server:createBulletinPost', function(source, data)
- local src = source
- if not CheckAuth(src) then return { success = false, error = 'Unauthorized' } end
- if not CheckPermission(src, 'bulletin_post') then
- return { success = false, error = 'No permission to create bulletin posts' }
- end
-
- data = data or {}
-
- -- Validate category exists for this job (or allow any stored value)
- local jobName = ps.getJobName(src)
- local jobRank = ps.getJobGradeName(src)
- local citizenId = ps.getIdentifier(src)
-
- local title = tostring(data.title or ''):gsub('^%s+', ''):gsub('%s+$', '')
- if title == '' then return { success = false, error = 'Title is required' } end
-
- local VALID_PRIORITIES = { low = true, normal = true, high = true, urgent = true }
- if not VALID_PRIORITIES[data.priority] then
- return { success = false, error = 'Invalid priority' }
- end
-
- -- Check category exists for job
- local catRow = MySQL.single.await(
- 'SELECT value FROM mdt_bulletin_categories WHERE value = ? AND job = ?',
- { data.category, jobName }
- )
- if not catRow then
- return { success = false, error = 'Invalid or unknown category' }
- end
-
- local profile = MySQL.single.await(
- 'SELECT fullname FROM mdt_profiles WHERE citizenid = ?',
- { citizenId }
- )
- local author = (profile and profile.fullname) or tostring(GetPlayerName(src) or 'Unknown')
-
- local canPin = CheckPermission(src, 'bulletin_pin')
- local pinned = (canPin and data.pinned == true) and 1 or 0
-
- local id = MySQL.insert.await([[
- INSERT INTO mdt_bulletin_posts
- (title, content, author, author_rank, category, priority, pinned, job, created_by)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
- ]], {
- title:sub(1, 255),
- tostring(data.content or ''):sub(1, 65535),
- author:sub(1, 100),
- jobRank,
- data.category,
- data.priority,
- pinned,
- jobName,
- citizenId
- })
-
- if not id then return { success = false, error = 'Database error' } end
- return { success = true, id = id }
-end)
-
--- ── Update a bulletin post ────────────────────────────────────
-
-ps.registerCallback(resourceName .. ':server:updateBulletinPost', function(source, postId, updates)
- local src = source
- if not CheckAuth(src) then return { success = false, error = 'Unauthorized' } end
-
- postId = tonumber(postId)
- updates = updates or {}
- if not postId then return { success = false, error = 'Invalid post id' } end
-
- local existing = MySQL.single.await(
- 'SELECT created_by, job FROM mdt_bulletin_posts WHERE id = ?',
- { postId }
- )
- if not existing then return { success = false, error = 'Post not found' } end
-
- local jobName = ps.getJobName(src)
- local citizenId = ps.getIdentifier(src)
- local isSupervisor = CheckPermission(src, 'bulletin_post')
- local isOwner = existing.created_by == citizenId
-
- if not isOwner and not isSupervisor then
- return { success = false, error = 'No permission to edit this post' }
- end
- if existing.job ~= jobName then
- return { success = false, error = 'Post belongs to a different department' }
- end
-
- local VALID_PRIORITIES = { low = true, normal = true, high = true, urgent = true }
-
- local sets = {}
- local vals = {}
-
- if updates.title ~= nil then
- local t = tostring(updates.title):gsub('^%s+', ''):gsub('%s+$', '')
- if t ~= '' then
- sets[#sets + 1] = 'title = ?'
- vals[#vals + 1] = t:sub(1, 255)
- end
- end
- if updates.content ~= nil then
- sets[#sets + 1] = 'content = ?'
- vals[#vals + 1] = tostring(updates.content):sub(1, 65535)
- end
- if updates.category ~= nil then
- -- Validate category belongs to this job
- local catRow = MySQL.single.await(
- 'SELECT value FROM mdt_bulletin_categories WHERE value = ? AND job = ?',
- { updates.category, jobName }
- )
- if catRow then
- sets[#sets + 1] = 'category = ?'
- vals[#vals + 1] = updates.category
- end
- end
- if updates.priority ~= nil and VALID_PRIORITIES[updates.priority] then
- sets[#sets + 1] = 'priority = ?'
- vals[#vals + 1] = updates.priority
- end
- if updates.pinned ~= nil and isSupervisor then
- sets[#sets + 1] = 'pinned = ?'
- vals[#vals + 1] = updates.pinned and 1 or 0
- end
-
- if #sets == 0 then return { success = false, error = 'No valid fields to update' } end
-
- vals[#vals + 1] = postId
- MySQL.update.await(
- 'UPDATE mdt_bulletin_posts SET ' .. table.concat(sets, ', ') .. ' WHERE id = ?',
- vals
- )
- return { success = true }
-end)
-
--- ── Delete a bulletin post ────────────────────────────────────
-
-ps.registerCallback(resourceName .. ':server:deleteBulletinPost', function(source, postId)
- local src = source
- if not CheckAuth(src) then return { success = false, error = 'Unauthorized' } end
-
- postId = tonumber(postId)
- if not postId then return { success = false, error = 'Invalid post id' } end
-
- local existing = MySQL.single.await(
- 'SELECT created_by, job FROM mdt_bulletin_posts WHERE id = ?',
- { postId }
- )
- if not existing then return { success = false, error = 'Post not found' } end
-
- local jobName = ps.getJobName(src)
- local citizenId = ps.getIdentifier(src)
- local isSupervisor = CheckPermission(src, 'bulletin_pin')
- local isOwner = existing.created_by == citizenId
-
- if not isOwner and not isSupervisor then
- return { success = false, error = 'No permission to delete this post' }
- end
- if existing.job ~= jobName then
- return { success = false, error = 'Post belongs to a different department' }
- end
-
- MySQL.update.await('DELETE FROM mdt_bulletin_posts WHERE id = ?', { postId })
- return { success = true }
-end)
-
--- ── Toggle pin on a bulletin post ────────────────────────────
-
-ps.registerCallback(resourceName .. ':server:toggleBulletinPin', function(source, postId)
- local src = source
- if not CheckAuth(src) then return { success = false, error = 'Unauthorized' } end
- if not CheckPermission(src, 'bulletin_pin') then
- return { success = false, error = 'No permission to pin posts' }
- end
-
- postId = tonumber(postId)
- if not postId then return { success = false, error = 'Invalid post id' } end
-
- local existing = MySQL.single.await(
- 'SELECT pinned, job FROM mdt_bulletin_posts WHERE id = ?',
- { postId }
- )
- if not existing then return { success = false, error = 'Post not found' } end
-
- local jobName = ps.getJobName(src)
- if existing.job ~= jobName then
- return { success = false, error = 'Post belongs to a different department' }
- end
-
- local isPinned = existing.pinned == 1 or existing.pinned == '1' or existing.pinned == true
- local newPinned = isPinned and 0 or 1
-
- MySQL.update.await(
- 'UPDATE mdt_bulletin_posts SET pinned = ? WHERE id = ?',
- { newPinned, postId }
- )
- return { success = true, pinned = newPinned }
-end)
-
--- ════════════════════════════════════════════════════════════
--- Category Management (one row per category)
---
--- Table: mdt_bulletin_categories
--- Columns: id, job, value, label, icon, color, sort_order, is_default
--- ════════════════════════════════════════════════════════════
-
--- Slugify a label into a safe DB value (e.g. "My Custom Cat" → "my_custom_cat")
-local function slugify(str)
- return tostring(str)
- :lower()
- :gsub('%s+', '_')
- :gsub('[^%w_]', '')
- :sub(1, 48)
-end
-
--- Seed default categories for a job if none exist yet
-local function ensureDefaultCategories(jobName)
- local count = MySQL.scalar.await(
- 'SELECT COUNT(*) FROM mdt_bulletin_categories WHERE job = ?',
- { jobName }
- )
- if (count or 0) > 0 then return end
-
- local defaults = {
- { value = 'announcement', label = 'Announcements', icon = 'campaign', color = '#3B82F6', sort_order = 1 },
- { value = 'operations', label = 'Operations', icon = 'local_police', color = '#8B5CF6', sort_order = 2 },
- { value = 'training', label = 'Training', icon = 'school', color = '#10B981', sort_order = 3 },
- { value = 'general', label = 'General', icon = 'forum', color = '#6B7280', sort_order = 4 },
- }
-
- for _, cat in ipairs(defaults) do
- MySQL.insert.await([[
- INSERT IGNORE INTO mdt_bulletin_categories
- (job, value, label, icon, color, sort_order, is_default)
- VALUES (?, ?, ?, ?, ?, ?, 1)
- ]], { jobName, cat.value, cat.label, cat.icon, cat.color, cat.sort_order })
- end
-end
-
--- ── GET categories ────────────────────────────────────────────
-
-ps.registerCallback(resourceName .. ':server:getBulletinCategories', function(source)
- local src = source
- if not CheckAuth(src) then return {} end
-
- local jobName = ps.getJobName(src)
- if not jobName or jobName == '' then return {} end
-
- ensureDefaultCategories(jobName)
-
- local ok, cats = pcall(MySQL.query.await, [[
- SELECT value, label, icon, color, sort_order, is_default
- FROM mdt_bulletin_categories
- WHERE job = ?
- ORDER BY sort_order ASC, id ASC
- ]], { jobName })
-
- if not ok or not cats then return {} end
-
- for _, c in ipairs(cats) do
- c.is_default = c.is_default == 1 or c.is_default == '1' or c.is_default == true
- end
-
- return cats
-end)
-
--- ── ADD category ──────────────────────────────────────────────
-
-ps.registerCallback(resourceName .. ':server:addBulletinCategory', function(source, data)
- local src = source
- if not CheckAuth(src) then return { success = false, error = 'Unauthorized' } end
- if not CheckPermission(src, 'bulletin_post') then
- return { success = false, error = 'No permission to manage categories' }
- end
-
- data = data or {}
- local jobName = ps.getJobName(src)
-
- local label = tostring(data.label or ''):gsub('^%s+', ''):gsub('%s+$', '')
- if label == '' then return { success = false, error = 'Label is required' } end
-
- -- Build value from label if not provided
- local value = (data.value and data.value ~= '') and slugify(data.value) or slugify(label)
- if value == '' then return { success = false, error = 'Could not generate a valid category key' } end
-
- -- Check for duplicate
- local existing = MySQL.scalar.await(
- 'SELECT COUNT(*) FROM mdt_bulletin_categories WHERE job = ? AND value = ?',
- { jobName, value }
- )
- if (existing or 0) > 0 then
- return { success = false, error = 'A category with that key already exists' }
- end
-
- -- Max 20 categories per job
- local total = MySQL.scalar.await(
- 'SELECT COUNT(*) FROM mdt_bulletin_categories WHERE job = ?',
- { jobName }
- )
- if (total or 0) >= 20 then
- return { success = false, error = 'Maximum of 20 categories per department reached' }
- end
-
- local nextOrder = MySQL.scalar.await(
- 'SELECT COALESCE(MAX(sort_order), 0) + 1 FROM mdt_bulletin_categories WHERE job = ?',
- { jobName }
- )
-
- local id = MySQL.insert.await([[
- INSERT INTO mdt_bulletin_categories (job, value, label, icon, color, sort_order, is_default)
- VALUES (?, ?, ?, ?, ?, ?, 0)
- ]], {
- jobName,
- value,
- label:sub(1, 48),
- tostring(data.icon or 'label'):sub(1, 48),
- tostring(data.color or '#6B7280'):sub(1, 7),
- nextOrder
- })
-
- if not id then return { success = false, error = 'Database error' } end
- return { success = true, value = value, id = id }
-end)
-
--- ── UPDATE category ───────────────────────────────────────────
-
-ps.registerCallback(resourceName .. ':server:updateBulletinCategory', function(source, data)
- local src = source
- if not CheckAuth(src) then return { success = false, error = 'Unauthorized' } end
- if not CheckPermission(src, 'bulletin_post') then
- return { success = false, error = 'No permission to manage categories' }
- end
-
- data = data or {}
- local jobName = ps.getJobName(src)
-
- local existing = MySQL.single.await(
- 'SELECT id, is_default FROM mdt_bulletin_categories WHERE job = ? AND value = ?',
- { jobName, data.value }
- )
- if not existing then return { success = false, error = 'Category not found' } end
-
- local sets = {}
- local vals = {}
-
- if data.label ~= nil then
- local l = tostring(data.label):gsub('^%s+', ''):gsub('%s+$', '')
- if l ~= '' then
- sets[#sets + 1] = 'label = ?'
- vals[#vals + 1] = l:sub(1, 48)
- end
- end
- if data.icon ~= nil then
- sets[#sets + 1] = 'icon = ?'
- vals[#vals + 1] = tostring(data.icon):sub(1, 48)
- end
- if data.color ~= nil then
- -- Validate hex color
- local color = tostring(data.color)
- if color:match('^#%x%x%x%x%x%x$') or color:match('^#%x%x%x$') then
- sets[#sets + 1] = 'color = ?'
- vals[#vals + 1] = color
- end
- end
-
- if #sets == 0 then return { success = false, error = 'No valid fields to update' } end
-
- vals[#vals + 1] = jobName
- vals[#vals + 1] = data.value
-
- MySQL.update.await(
- 'UPDATE mdt_bulletin_categories SET ' .. table.concat(sets, ', ') ..
- ' WHERE job = ? AND value = ?',
- vals
- )
- return { success = true }
-end)
-
--- ── REMOVE category ───────────────────────────────────────────
-
-ps.registerCallback(resourceName .. ':server:removeBulletinCategory', function(source, data)
- local src = source
- if not CheckAuth(src) then return { success = false, error = 'Unauthorized' } end
- if not CheckPermission(src, 'bulletin_post') then
- return { success = false, error = 'No permission to manage categories' }
- end
-
- -- Accept either a table { value = '...' } or a plain string (backwards compat)
- local value = type(data) == 'table' and tostring(data.value or '') or tostring(data or '')
- local jobName = ps.getJobName(src)
-
- if value == '' then return { success = false, error = 'Missing category value' } end
-
- local existing = MySQL.single.await(
- 'SELECT id, is_default FROM mdt_bulletin_categories WHERE job = ? AND value = ?',
- { jobName, value }
- )
- if not existing then return { success = false, error = 'Category not found' } end
-
- -- Count remaining categories — keep at least 1
- local total = MySQL.scalar.await(
- 'SELECT COUNT(*) FROM mdt_bulletin_categories WHERE job = ?',
- { jobName }
- )
- if (total or 0) <= 1 then
- return { success = false, error = 'Cannot remove the last category' }
- end
-
- -- Reassign posts in this category to 'general' (or the first remaining category)
- local fallback = MySQL.single.await(
- 'SELECT value FROM mdt_bulletin_categories WHERE job = ? AND value != ? ORDER BY sort_order ASC LIMIT 1',
- { jobName, value }
- )
- if fallback then
- MySQL.update.await(
- 'UPDATE mdt_bulletin_posts SET category = ? WHERE job = ? AND category = ?',
- { fallback.value, jobName, value }
- )
- end
-
- MySQL.update.await(
- 'DELETE FROM mdt_bulletin_categories WHERE job = ? AND value = ?',
- { jobName, value }
- )
- return { success = true }
-end)
-
--- ── REORDER categories ────────────────────────────────────────
-
-ps.registerCallback(resourceName .. ':server:reorderBulletinCategories', function(source, data)
- local src = source
- if not CheckAuth(src) then return { success = false, error = 'Unauthorized' } end
- if not CheckPermission(src, 'bulletin_post') then
- return { success = false, error = 'No permission to manage categories' }
- end
-
- local jobName = ps.getJobName(src)
-
- -- data may arrive as the array directly, or wrapped: { order = [...] }
- local order = data
- if type(data) == 'table' and data.order then
- order = data.order
- end
-
- if type(order) ~= 'table' then
- return { success = false, error = 'Invalid order data' }
- end
-
- for i, item in ipairs(order) do
- local val = item.value and tostring(item.value) or nil
- local sord = item.sort_order and tonumber(item.sort_order) or i
- if val then
- MySQL.update.await(
- 'UPDATE mdt_bulletin_categories SET sort_order = ? WHERE job = ? AND value = ?',
- { sord, jobName, val }
- )
- end
- end
-
- return { success = true }
-end)
\ No newline at end of file
diff --git a/server/backend/cameras.lua b/server/backend/cameras.lua
index d4616c29..74cc73db 100644
--- a/server/backend/cameras.lua
+++ b/server/backend/cameras.lua
@@ -2,7 +2,6 @@ local resourceName = tostring(GetCurrentResourceName())
-- Get available camera models for camera creation
ps.registerCallback(resourceName .. ':server:getCameraModels', function(source)
- if not CheckAuth(source) then return {} end
-- Import Camera models from main cameras.lua
local Camera = _G.Camera or {}
local models = {}
@@ -29,7 +28,6 @@ end)
-- Validate camera model
ps.registerCallback(resourceName .. ':server:validateCameraModel', function(source, modelKey)
- if not CheckAuth(source) then return false end
-- Import Camera models from main cameras.lua
local Camera = _G.Camera or {}
local isValid = (Camera.models or {})[modelKey] ~= nil
diff --git a/server/backend/cases.lua b/server/backend/cases.lua
index a69216a2..2d510a0e 100644
--- a/server/backend/cases.lua
+++ b/server/backend/cases.lua
@@ -549,40 +549,21 @@ ps.registerCallback(resourceName .. ':server:removeCaseAttachment', function(sou
return { success = true }
end)
-ps.registerCallback(resourceName .. ':server:addEvidenceItem', function(source, payload)
+ps.registerCallback(resourceName .. ':server:addEvidenceItem', function(source, caseId, evidence)
local src = source
if not CheckAuth(src) then return { success = false, error = 'Unauthorized' } end
- payload = payload or {}
- local caseId = tonumber(payload.caseId)
- local reportId = tonumber(payload.reportId)
- local evidence = payload.evidence or payload
-
- if not evidence or not evidence.title then
- return { success = false, error = 'Invalid evidence: title is required' }
- end
-
- if caseId then
- local caseExists = MySQL.single.await('SELECT id FROM mdt_cases WHERE id = ?', { caseId })
- if not caseExists then
- caseId = nil
- end
- end
-
- if reportId then
- local reportExists = MySQL.single.await('SELECT id FROM mdt_reports WHERE id = ?', { reportId })
- if not reportExists then
- reportId = nil
- end
+ caseId = tonumber(caseId)
+ if not caseId or not evidence or not evidence.title then
+ return { success = false, error = 'Invalid evidence' }
end
local evidenceId = MySQL.insert.await([[
INSERT INTO mdt_evidence_items
- (case_id, report_id, title, type, serial, notes, location, stash_id, stored, last_holder, created_by)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ (case_id, title, type, serial, notes, location, stash_id, stored, last_holder, created_by)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
]], {
caseId,
- reportId,
evidence.title,
evidence.type or 'Evidence',
evidence.serial or '',
@@ -610,6 +591,8 @@ ps.registerCallback(resourceName .. ':server:addEvidenceItem', function(source,
return { success = true, id = evidenceId }
end)
+-- addEvidenceImage handler is in evidence.lua (not duplicated here)
+
ps.registerCallback(resourceName .. ':server:removeEvidenceImage', function(source, imageId)
local src = source
if not CheckAuth(src) then return { success = false, error = 'Unauthorized' } end
diff --git a/server/backend/citizens.lua b/server/backend/citizens.lua
index 16e238b1..8cde669c 100644
--- a/server/backend/citizens.lua
+++ b/server/backend/citizens.lua
@@ -100,9 +100,7 @@ ps.registerCallback(resourceName .. ':server:getCitizens', function(source, page
JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.gender')) AS gender,
JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.birthdate')) AS dateofbirth,
JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.phone')) AS phone,
- JSON_UNQUOTE(JSON_EXTRACT(p.job, '$.label')) AS job,
- JSON_UNQUOTE(JSON_EXTRACT(p.metadata, '$.fingerprint')) AS fingerprint,
- JSON_UNQUOTE(JSON_EXTRACT(p.metadata, '$.dna')) AS dna
+ JSON_UNQUOTE(JSON_EXTRACT(p.job, '$.label')) AS job
FROM players AS p
LEFT JOIN mdt_profiles AS mp
ON CONVERT(p.citizenid USING utf8mb4) COLLATE utf8mb4_general_ci = CONVERT(mp.citizenid USING utf8mb4) COLLATE utf8mb4_general_ci
@@ -145,11 +143,11 @@ ps.registerCallback(resourceName .. ':server:getCitizens', function(source, page
end
local propRows = safeQuery(
- ('SELECT owner, COUNT(*) AS cnt FROM properties WHERE owner IN (%s) GROUP BY owner'):format(inClause),
+ ('SELECT citizenid, COUNT(*) AS cnt FROM player_houses WHERE citizenid IN (%s) GROUP BY citizenid'):format(inClause),
citizenids
)
for _, row in ipairs(propRows) do
- propCounts[row.owner] = tonumber(row.cnt) or 0
+ propCounts[row.citizenid] = tonumber(row.cnt) or 0
end
local vehRows = safeQuery(
@@ -178,8 +176,6 @@ ps.registerCallback(resourceName .. ':server:getCitizens', function(source, page
v.dob = v.dateofbirth
v.phone = v.phone
v.image = profilePics[v.citizenid] or nil
- v.fingerprint = v.fingerprint or nil
- v.dna = v.dna or nil
v.occupations = { v.job }
v.properties = propCounts[v.citizenid] or 0
v.vehicles = vehCounts[v.citizenid] or 0
@@ -220,15 +216,14 @@ ps.registerCallback(resourceName .. ':server:searchCitizens', function(source, q
-- Build a complex search query that searches across multiple fields and returns same data as getCitizens
local sqlQuery = [[
SELECT DISTINCT
- mp.id, p.citizenid,
+ mp.id,
+ p.citizenid,
JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.firstname')) AS firstname,
JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.lastname')) AS lastname,
JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.gender')) AS gender,
JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.birthdate')) AS dateofbirth,
JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.phone')) AS phone,
- JSON_UNQUOTE(JSON_EXTRACT(p.job, '$.label')) AS job,
- JSON_UNQUOTE(JSON_EXTRACT(p.metadata, '$.fingerprint')) AS fingerprint,
- JSON_UNQUOTE(JSON_EXTRACT(p.metadata, '$.dna')) AS dna
+ JSON_UNQUOTE(JSON_EXTRACT(p.job, '$.label')) AS job
FROM players AS p
LEFT JOIN mdt_profiles AS mp ON p.citizenid COLLATE utf8mb4_general_ci = mp.citizenid COLLATE utf8mb4_general_ci
WHERE
@@ -237,15 +232,13 @@ ps.registerCallback(resourceName .. ':server:searchCitizens', function(source, q
LOWER(CONCAT(JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.firstname')), ' ', JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.lastname')))) LIKE ? OR
LOWER(p.citizenid) LIKE ? OR
LOWER(JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.phone'))) LIKE ? OR
- LOWER(JSON_UNQUOTE(JSON_EXTRACT(p.job, '$.label'))) LIKE ? OR
- LOWER(JSON_UNQUOTE(JSON_EXTRACT(p.metadata, '$.fingerprint'))) LIKE ? OR
- LOWER(JSON_UNQUOTE(JSON_EXTRACT(p.metadata, '$.dna'))) LIKE ?
+ LOWER(JSON_UNQUOTE(JSON_EXTRACT(p.job, '$.label'))) LIKE ?
LIMIT ?
]]
+ local searchLimit = Config.Pagination and Config.Pagination.CitizenSearch or 20
local result = safeQuery(sqlQuery, {
- searchTerm, searchTerm, searchTerm, searchTerm,
- searchTerm, searchTerm, searchTerm, searchTerm
+ searchTerm, searchTerm, searchTerm, searchTerm, searchTerm, searchTerm, searchLimit
})
if not result or #result == 0 then return {} end
@@ -283,11 +276,11 @@ ps.registerCallback(resourceName .. ':server:searchCitizens', function(source, q
end
local propRows = safeQuery(
- ('SELECT owner, COUNT(*) AS cnt FROM properties WHERE owner IN (%s) GROUP BY owner'):format(inClause),
+ ('SELECT citizenid, COUNT(*) AS cnt FROM player_houses WHERE citizenid IN (%s) GROUP BY citizenid'):format(inClause),
citizenids
)
for _, row in ipairs(propRows) do
- propCounts[row.owner] = tonumber(row.cnt) or 0
+ propCounts[row.citizenid] = tonumber(row.cnt) or 0
end
local vehRows = safeQuery(
@@ -427,7 +420,7 @@ ps.registerCallback(resourceName .. ':server:getCitizenProfile', function(source
local flags = collectCitizenFlags({ citizenid })
local vehicles = MySQL.query.await('SELECT plate, vehicle FROM player_vehicles WHERE citizenid = ?', { citizenid }) or {}
local vehiclesCount = #vehicles
- local properties = MySQL.query.await('SELECT id, property_name, coords, keyholders FROM properties WHERE owner = ?', { citizenid }) or {}
+ local properties = MySQL.query.await('SELECT house FROM player_houses WHERE citizenid = ?', { citizenid }) or {}
local propertiesCount = #properties
local arrestsCount = MySQL.scalar.await('SELECT COUNT(*) FROM mdt_arrests WHERE citizenid = ?', { citizenid }) or 0
local activeWarrants = MySQL.query.await([[
@@ -517,25 +510,12 @@ ps.registerCallback(resourceName .. ':server:getCitizenProfile', function(source
end
local weapons = MySQL.query.await([[
- SELECT id, serial, scratched, owner, information, weaponClass, weaponModel, flags
+ SELECT id, serial, scratched, owner, information, weaponClass, weaponModel
FROM mdt_weapons
WHERE owner = ?
ORDER BY id DESC
]], { citizenid }) or {}
- local weaponsMapped = {}
- for _, w in ipairs(weapons) do
- weaponsMapped[#weaponsMapped + 1] = {
- id = w.id,
- serial = w.serial,
- scratched = w.scratched,
- information = w.information,
- weaponClass = w.weaponClass,
- weaponModel = w.weaponModel,
- flags = w.flags and json.decode(w.flags) or {},
- }
- end
-
local linkedReports = {}
if #involvedReportIds > 0 then
local placeholders = (string.rep('?,', #involvedReportIds)):sub(1, -2)
@@ -602,7 +582,7 @@ ps.registerCallback(resourceName .. ':server:getCitizenProfile', function(source
activeWarrants = activeWarrants,
activeBolos = activeBoloDetails,
evidence = evidence,
- weapons = weaponsMapped,
+ weapons = weapons,
linkedReports = linkedReports,
ownedVehicles = vehicles,
propertiesList = properties,
@@ -1075,14 +1055,9 @@ ps.registerCallback(resourceName .. ':server:getMyProfile', function(source)
if rid and not reportIdSet[rid] then reportIdSet[rid] = true end
end
- local reportIds = {}
- for rid in pairs(reportIdSet) do reportIds[#reportIds + 1] = rid end
- if #reportIds > 0 then
- local placeholders = string.rep('?,', #reportIds - 1) .. '?'
- local lrOk, reports = pcall(MySQL.query.await,
- 'SELECT id, title, type FROM mdt_reports WHERE id IN (' .. placeholders .. ')',
- reportIds)
- for _, report in ipairs(lrOk and reports or {}) do
+ for rid, _ in pairs(reportIdSet) do
+ local rOk, report = pcall(MySQL.single.await, 'SELECT id, title, type FROM mdt_reports WHERE id = ?', { rid })
+ if rOk and report then
linkedReports[#linkedReports + 1] = { id = report.id, title = report.title, type = report.type }
end
end
@@ -1133,114 +1108,3 @@ ps.registerCallback(resourceName .. ':server:getMyProfile', function(source)
}
}
end)
-
-ps.registerCallback(resourceName .. ':server:getProperty', function(source, propertyId)
- local src = source
- if not CheckAuth(src) then return { success = false, message = 'Unauthorized' } end
-
- if not propertyId then
- return { success = false, message = 'Missing property id' }
- end
-
- local propRow = MySQL.single.await([[
- SELECT id, property_name, coords, owner, keyholders
- FROM properties
- WHERE id = ?
- LIMIT 1
- ]], { propertyId })
-
- if not propRow then
- return { success = false, message = 'Property not found' }
- end
-
- -- Decode coords JSON → table
- local coords = nil
- if propRow.coords and propRow.coords ~= '' then
- local ok, decoded = pcall(json.decode, propRow.coords)
- if ok and decoded then
- coords = decoded
- end
- end
-
- -- Resolve owner citizenid → full name
- local ownerName = nil
- if propRow.owner and propRow.owner ~= '' then
- local ownerRow = MySQL.single.await([[
- SELECT JSON_UNQUOTE(JSON_EXTRACT(charinfo, '$.firstname')) AS firstname,
- JSON_UNQUOTE(JSON_EXTRACT(charinfo, '$.lastname')) AS lastname
- FROM players
- WHERE citizenid = ?
- LIMIT 1
- ]], { propRow.owner })
- if ownerRow then
- ownerName = (ownerRow.firstname or '') .. ' ' .. (ownerRow.lastname or '')
- ownerName = ownerName:match('^%s*(.-)%s*$') -- trim
- end
- end
-
- -- Decode keyholders JSON → array of citizenids
- -- The column stores either a JSON array of citizenids (["CID1","CID2",...])
- -- or a JSON object keyed by citizenid — we handle both.
- local keyholderList = {}
- if propRow.keyholders and propRow.keyholders ~= '' and propRow.keyholders ~= '{}' and propRow.keyholders ~= '[]' then
- local ok, decoded = pcall(json.decode, propRow.keyholders)
- if ok and decoded then
- -- Array form: ["CID1", "CID2"]
- if decoded[1] ~= nil then
- for _, cid in ipairs(decoded) do
- keyholderList[#keyholderList + 1] = tostring(cid)
- end
- else
- -- Object/map form: { CID1 = true, CID2 = 1, ... }
- for cid, _ in pairs(decoded) do
- keyholderList[#keyholderList + 1] = tostring(cid)
- end
- end
- end
- end
-
- -- Batch-resolve keyholder names
- local keyholders = {}
- if #keyholderList > 0 then
- local placeholders = {}
- for i = 1, #keyholderList do placeholders[i] = '?' end
- local khRows = MySQL.query.await(([[
- SELECT citizenid,
- JSON_UNQUOTE(JSON_EXTRACT(charinfo, '$.firstname')) AS firstname,
- JSON_UNQUOTE(JSON_EXTRACT(charinfo, '$.lastname')) AS lastname
- FROM players
- WHERE citizenid IN (%s)
- ]]):format(table.concat(placeholders, ',')), keyholderList)
-
- -- Build a lookup map for fast access
- local nameMap = {}
- for _, row in ipairs(khRows or {}) do
- local full = ((row.firstname or '') .. ' ' .. (row.lastname or '')):match('^%s*(.-)%s*$')
- nameMap[row.citizenid] = full ~= '' and full or nil
- end
-
- -- Preserve original keyholder order
- for _, cid in ipairs(keyholderList) do
- -- Skip if the keyholder is the owner (already shown as owner)
- if cid ~= propRow.owner then
- keyholders[#keyholders + 1] = {
- citizenid = cid,
- name = nameMap[cid] or 'Unknown',
- }
- end
- end
- end
-
-
- return {
- success = true,
- property = {
- property_name = propRow.property_name,
- coords = coords,
- streetName = propRow.streetName,
- owner = propRow.owner or nil,
- ownerName = ownerName,
- keyholders = keyholders,
- }
- }
-end)
\ No newline at end of file
diff --git a/server/backend/collab.lua b/server/backend/collab.lua
index 669fca3a..10aff30d 100644
--- a/server/backend/collab.lua
+++ b/server/backend/collab.lua
@@ -119,7 +119,7 @@ ps.registerCallback(resourceName .. ':server:joinReportSession', function(source
myName = editorName,
myCitizenId = citizenId,
editors = getEditorsList(session, src),
- yjsUpdates = session.yjsUpdates or {},
+ yjsState = session.yjsState,
lastStructuredData = session.lastStructuredData,
}
end)
@@ -167,6 +167,11 @@ AddEventHandler(resourceName .. ':server:collabSyncYjs', function(reportId, upda
-- Store for late joiners
if not session.yjsUpdates then session.yjsUpdates = {} end
session.yjsUpdates[#session.yjsUpdates + 1] = update
+ session.yjsState = update
+ if #session.yjsUpdates > 100 then
+ session.yjsState = update
+ session.yjsUpdates = { update }
+ end
-- Queue for batched broadcast
if not yjsPendingBroadcasts[reportId] then
@@ -220,24 +225,6 @@ AddEventHandler(resourceName .. ':server:collabSyncData', function(reportId, dat
}, src)
end)
-RegisterNetEvent(resourceName .. ':server:collabSyncAwareness')
-AddEventHandler(resourceName .. ':server:collabSyncAwareness', function(reportId, update)
- local src = source
- if not CheckAuth(src) then return end
- reportId = tostring(reportId)
- local session = activeReportSessions[reportId]
- if not session or not session.editors[src] then return end
-
- for editorSrc, _ in pairs(session.editors) do
- if editorSrc ~= src then
- TriggerClientEvent(resourceName .. ':client:awarenessBatch', editorSrc, {
- reportId = reportId,
- updates = { update },
- })
- end
- end
-end)
-
-- Cleanup on player disconnect
AddEventHandler('playerDropped', function()
local src = source
diff --git a/server/backend/dashboard.lua b/server/backend/dashboard.lua
index c9daaccd..17a1f6a8 100644
--- a/server/backend/dashboard.lua
+++ b/server/backend/dashboard.lua
@@ -1,3 +1,4 @@
+
local resourceName = tostring(GetCurrentResourceName())
local function getEffectiveJobType(src)
@@ -15,10 +16,9 @@ end
ps.registerCallback(resourceName .. ':server:getJobData', function(source)
local src = source
assert(src, 'Player ID cannot be nil')
- if not CheckAuth(src) then return {} end
local response = {
- rank = ps.getJobGradeName(src) or 'Officer',
- payRate = '$' .. (ps.getJobGradePay(src) or 300) .. '/hr',
+ rank = ps.getJobGradeName(src) or "Officer",
+ payRate = "$" .. (ps.getJobGradePay(src) or 300) .. "/hr",
}
return response
end)
@@ -26,7 +26,6 @@ end)
ps.registerCallback(resourceName .. ':server:getReportStatistics', function(source)
local src = source
assert(src, 'Player ID cannot be nil')
- if not CheckAuth(src) then return {} end
return Cache.getOrSet('dashboard:reportStats', Config.CacheTTL and Config.CacheTTL.ReportStats or 30, function()
local response = MySQL.query.await([[
@@ -37,17 +36,28 @@ ps.registerCallback(resourceName .. ':server:getReportStatistics', function(sour
]], {})
local row = response and response[1] or { totalThisWeek = 0, totalLastWeek = 0 }
- return {
- totalThisWeek = tonumber(row.totalThisWeek) or 0,
- changeFromLastWeek = (tonumber(row.totalThisWeek) or 0) - (tonumber(row.totalLastWeek) or 0),
+ local reportStatistics = {
+ totalThisWeek = tonumber(row.totalThisWeek) or 0,
+ changeFromLastWeek = (tonumber(row.totalThisWeek) or 0) - (tonumber(row.totalLastWeek) or 0)
}
+ return reportStatistics
end)
end)
+local function parseDateOnly(value)
+ if not value then
+ return nil
+ end
+ local year, month, day = tostring(value):match('^(%d%d%d%d)%-(%d%d)%-(%d%d)$')
+ if not year then
+ return nil
+ end
+ return os.time({ year = tonumber(year), month = tonumber(month), day = tonumber(day), hour = 0 })
+end
+
ps.registerCallback(resourceName .. ':server:getTimeStatistics', function(source)
local src = source
assert(src, 'Player ID cannot be nil')
- if not CheckAuth(src) then return {} end
local citizenid = ps.getIdentifier(src)
if not citizenid then return {} end
@@ -68,14 +78,16 @@ ps.registerCallback(resourceName .. ':server:getTimeStatistics', function(source
local result = {}
for i = 6, 0, -1 do
- local dayTs = os.time() - (i * 24 * 60 * 60)
+ local dayTs = os.time() - (i * 24 * 60 * 60)
local dayKey = os.date('%Y-%m-%d', dayTs)
- local label = os.date('%a', dayTs)
+ local label = os.date('%a', dayTs)
+ local seconds = secondsByDay[dayKey] or 0
result[#result + 1] = {
- day = label,
- hours = math.floor(((secondsByDay[dayKey] or 0) / 3600) * 10) / 10,
+ day = label,
+ hours = math.floor((seconds / 3600) * 10) / 10
}
end
+
return result
end)
@@ -129,17 +141,26 @@ ps.registerCallback(resourceName .. ':server:deleteBulletin', function(source, p
end)
ps.registerCallback(resourceName .. ':server:getRecentReports', function(source, page, limit)
- local src = source
+ local src = source
assert(src, 'Player ID cannot be nil')
- if not CheckAuth(src) then return {} end
- local pageNumber = math.max(1, tonumber(page) or 1)
- local pageSize = math.min(50, math.max(1, tonumber(limit) or 10))
+ local pageNumber = tonumber(page) or 1
+ if pageNumber < 1 then
+ pageNumber = 1
+ end
+
+ local pageSize = tonumber(limit) or 10
+ if pageSize < 1 then
+ pageSize = 10
+ end
+ if pageSize > 50 then
+ pageSize = 50
+ end
local identifier = ps.getIdentifier(src)
- local job = ps.getJobName(src)
- local jobType = getEffectiveJobType(src)
- local offset = (pageNumber - 1) * pageSize
+ local job = ps.getJobName(src)
+ local jobType = getEffectiveJobType(src)
+ local offset = (pageNumber - 1) * pageSize
local rows = MySQL.query.await([[
SELECT mr.id, mr.title, mr.type, mr.contentplaintext, mr.author, mr.authorplaintext, mr.datecreated, mr.dateupdated
FROM mdt_reports mr
@@ -151,8 +172,8 @@ ps.registerCallback(resourceName .. ':server:getRecentReports', function(source,
))
OR (mrr.reportid IS NULL AND (? = 'leo' OR ? = 'ems'))
OR (mrr.type = 'citizenid' AND mrr.identifier = ?)
- OR (mrr.type = 'job' AND mrr.identifier = ?)
- OR (mrr.type = 'jobtype' AND mrr.identifier = ?)
+ OR (mrr.type = 'job' AND mrr.identifier = ?)
+ OR (mrr.type = 'jobtype' AND mrr.identifier = ?)
)
GROUP BY mr.id
ORDER BY mr.datecreated DESC
@@ -169,14 +190,15 @@ ps.registerCallback(resourceName .. ':server:getActiveBolos', function(source)
local BOLOS = MySQL.query.await('SELECT * FROM mdt_bolos WHERE status = ? ORDER BY id DESC', { 'active' })
local result = {}
for _, v in pairs(BOLOS or {}) do
- result[#result + 1] = {
- id = v.id,
+ local formattedBolo = {
+ id = v.id,
reportId = v.reportId and tostring(v.reportId) or 'N/A',
- name = v.subject_name or ps.getPlayerNameByIdentifier(v.subject_id) or 'Unknown',
- type = v.type,
- notes = v.notes or '',
- status = v.status,
+ name = v.subject_name or ps.getPlayerNameByIdentifier(v.subject_id) or 'Unknown',
+ type = v.type,
+ notes = v.notes or '',
+ status = v.status,
}
+ table.insert(result, formattedBolo)
end
ps.debug('Fetched ' .. #result .. ' active BOLOs from database for source ' .. src, result)
return result
@@ -185,33 +207,37 @@ end)
ps.registerCallback(resourceName .. ':server:getActiveUnits', function(source)
local src = source
assert(src, 'Player ID cannot be nil')
- if not CheckAuth(src) then return { count = 0 } end
return Cache.getOrSet('dashboard:activeUnits', Config.CacheTTL and Config.CacheTTL.ActiveUnits or 10, function()
return { count = ps.getJobTypeCount('leo') }
end)
end)
+-- Sanitize dispatch data for safe serialization through ps.callback
+-- ps-dispatch objects contain vectors/coords that msgpack can't serialize
local function sanitizeDispatch(call)
if not call or type(call) ~= 'table' then return nil end
+
local sanitized = {
- id = call.id,
- message = call.message or call.dispatchMessage or '',
- code = call.code or call.dispatchCode or '',
- street = call.street or '',
- priority = call.priority or 0,
- time = call.time or 0,
- gender = call.gender,
- plate = call.plate,
- color = call.color,
- model = call.model,
- weapon = call.weapon,
- heading = call.heading,
- speed = call.speed,
- callSign = call.callSign,
+ id = call.id,
+ message = call.message or call.dispatchMessage or '',
+ code = call.code or call.dispatchCode or '',
+ street = call.street or '',
+ priority = call.priority or 0,
+ time = call.time or 0,
+ gender = call.gender,
+ plate = call.plate,
+ color = call.color,
+ model = call.model,
+ weapon = call.weapon,
+ heading = call.heading,
+ speed = call.speed,
+ callSign = call.callSign,
description = call.description,
- camId = call.camId,
- firstColor = call.firstColor,
+ camId = call.camId,
+ firstColor = call.firstColor,
}
+
+ -- Sanitize coords (vectors can't serialize)
if call.coords then
if type(call.coords) == 'vector3' or type(call.coords) == 'vector4' then
sanitized.coords = { x = call.coords.x, y = call.coords.y, z = call.coords.z }
@@ -219,32 +245,36 @@ local function sanitizeDispatch(call)
sanitized.coords = { x = call.coords.x or call.coords[1], y = call.coords.y or call.coords[2], z = call.coords.z or call.coords[3] }
end
end
+
+ -- Sanitize units array
sanitized.units = {}
if call.units and type(call.units) == 'table' then
for _, unit in pairs(call.units) do
if type(unit) == 'table' then
sanitized.units[#sanitized.units + 1] = {
citizenid = unit.citizenid,
- charinfo = unit.charinfo,
- job = unit.job,
- metadata = unit.metadata and { callsign = unit.metadata.callsign } or nil,
+ charinfo = unit.charinfo,
+ job = unit.job,
+ metadata = unit.metadata and { callsign = unit.metadata.callsign } or nil,
}
end
end
end
+
+ -- Sanitize jobs array
if call.jobs and type(call.jobs) == 'table' then
sanitized.jobs = {}
for _, job in ipairs(call.jobs) do
sanitized.jobs[#sanitized.jobs + 1] = job
end
end
+
return sanitized
end
ps.registerCallback(resourceName .. ':server:getRecentDispatches', function(source)
local src = source
assert(src, 'Player ID cannot be nil')
- if not CheckAuth(src) then return {} end
local dispatchResource = Config and Config.Dispatch and Config.Dispatch.Resource or 'ps-dispatch'
local ok, recentDispatches = pcall(function()
return exports[dispatchResource] and exports[dispatchResource]:GetDispatchCalls() or {}
@@ -252,6 +282,7 @@ ps.registerCallback(resourceName .. ':server:getRecentDispatches', function(sour
if not ok then return {} end
recentDispatches = recentDispatches or {}
+ -- Filter by job if configured
local dispatches = recentDispatches
if Config and Config.Dispatch and Config.Dispatch.FilterByJob == true then
local jobName = ps.getJobName(src)
@@ -262,23 +293,31 @@ ps.registerCallback(resourceName .. ':server:getRecentDispatches', function(sour
if not call.jobs or #call.jobs == 0 then
filtered[#filtered + 1] = call
else
+ local matched = false
for _, job in ipairs(call.jobs) do
if job == jobName or job == jobType then
- filtered[#filtered + 1] = call
+ matched = true
break
end
end
+ if matched then
+ filtered[#filtered + 1] = call
+ end
end
end
dispatches = filtered
end
end
+ -- Sanitize for serialization
local result = {}
for _, call in ipairs(dispatches) do
local sanitized = sanitizeDispatch(call)
- if sanitized then result[#result + 1] = sanitized end
+ if sanitized then
+ result[#result + 1] = sanitized
+ end
end
+
return result
end)
@@ -287,22 +326,33 @@ ps.registerCallback(resourceName .. ':server:getUsageMetrics', function(source)
if not CheckAuth(src) then return {} end
return Cache.getOrSet('dashboard:usageMetrics', Config.CacheTTL and Config.CacheTTL.UsageMetrics or 60, function()
+ -- Use separate queries with pcall to handle missing tables gracefully
local function safeCount(query, params)
local ok, result = pcall(MySQL.scalar.await, query, params or {})
- return ok and (tonumber(result) or 0) or 0
+ if ok then return tonumber(result) or 0 end
+ return 0
end
+
+ local totalReports = safeCount('SELECT COUNT(*) FROM mdt_reports')
+ local reportsLast7 = safeCount('SELECT COUNT(*) FROM mdt_reports WHERE datecreated >= NOW() - INTERVAL 7 DAY')
+ local reportsLast30 = safeCount('SELECT COUNT(*) FROM mdt_reports WHERE datecreated >= NOW() - INTERVAL 30 DAY')
+ local totalArrests = safeCount('SELECT COUNT(*) FROM mdt_arrests')
+ local arrestsLast7 = safeCount('SELECT COUNT(*) FROM mdt_arrests WHERE created_at >= NOW() - INTERVAL 7 DAY')
+ local arrestsLast30 = safeCount('SELECT COUNT(*) FROM mdt_arrests WHERE created_at >= NOW() - INTERVAL 30 DAY')
+ local activeWarrants = safeCount('SELECT COUNT(*) FROM mdt_reports_warrants WHERE expirydate >= NOW()')
+
return {
totals = {
- reports = safeCount('SELECT COUNT(*) FROM mdt_reports'),
- arrests = safeCount('SELECT COUNT(*) FROM mdt_arrests'),
- activeWarrants = safeCount('SELECT COUNT(*) FROM mdt_reports_warrants WHERE expirydate >= NOW()'),
+ reports = totalReports,
+ arrests = totalArrests,
+ activeWarrants = activeWarrants,
},
windows = {
- reportsLast7 = safeCount('SELECT COUNT(*) FROM mdt_reports WHERE datecreated >= NOW() - INTERVAL 7 DAY'),
- reportsLast30 = safeCount('SELECT COUNT(*) FROM mdt_reports WHERE datecreated >= NOW() - INTERVAL 30 DAY'),
- arrestsLast7 = safeCount('SELECT COUNT(*) FROM mdt_arrests WHERE created_at >= NOW() - INTERVAL 7 DAY'),
- arrestsLast30 = safeCount('SELECT COUNT(*) FROM mdt_arrests WHERE created_at >= NOW() - INTERVAL 30 DAY'),
- },
+ reportsLast7 = reportsLast7,
+ reportsLast30 = reportsLast30,
+ arrestsLast7 = arrestsLast7,
+ arrestsLast30 = arrestsLast30,
+ }
}
end)
-end)
\ No newline at end of file
+end)
diff --git a/server/backend/evidence.lua b/server/backend/evidence.lua
index 858c8b6c..29b9230d 100644
--- a/server/backend/evidence.lua
+++ b/server/backend/evidence.lua
@@ -42,37 +42,6 @@ ps.registerCallback(resourceName .. ':server:getEvidenceItems', function(source,
LIMIT ? OFFSET ?
]]):format(whereClause), listValues)
- if evidence and #evidence > 0 then
- local ids = {}
- local idLookup = {}
- for _, item in ipairs(evidence) do
- ids[#ids + 1] = item.id
- idLookup[item.id] = item
- item.images = {}
- end
-
- local placeholders = string.rep('?,', #ids - 1) .. '?'
- local images = MySQL.query.await(([[
- SELECT id, evidence_id, url, label, uploaded_by, uploaded_at
- FROM mdt_evidence_images
- WHERE evidence_id IN (%s)
- ORDER BY uploaded_at DESC
- ]]):format(placeholders), ids)
-
- for _, img in ipairs(images or {}) do
- local parent = idLookup[img.evidence_id]
- if parent then
- parent.images[#parent.images + 1] = {
- id = img.id,
- url = img.url,
- label = img.label,
- uploaded_by = img.uploaded_by,
- uploaded_at = img.uploaded_at
- }
- end
- end
- end
-
return {
success = true,
data = {
@@ -114,37 +83,6 @@ ps.registerCallback(resourceName .. ':server:searchEvidenceItems', function(sour
LIMIT ? OFFSET ?
]], { likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, limit, offset })
- if evidence and #evidence > 0 then
- local ids = {}
- local idLookup = {}
- for _, item in ipairs(evidence) do
- ids[#ids + 1] = item.id
- idLookup[item.id] = item
- item.images = {}
- end
-
- local placeholders = string.rep('?,', #ids - 1) .. '?'
- local images = MySQL.query.await(([[
- SELECT id, evidence_id, url, label, uploaded_by, uploaded_at
- FROM mdt_evidence_images
- WHERE evidence_id IN (%s)
- ORDER BY uploaded_at DESC
- ]]):format(placeholders), ids)
-
- for _, img in ipairs(images or {}) do
- local parent = idLookup[img.evidence_id]
- if parent then
- parent.images[#parent.images + 1] = {
- id = img.id,
- url = img.url,
- label = img.label,
- uploaded_by = img.uploaded_by,
- uploaded_at = img.uploaded_at
- }
- end
- end
- end
-
return {
success = true,
data = {
@@ -161,36 +99,14 @@ ps.registerCallback(resourceName .. ':server:addEvidenceItem', function(source,
if not CheckAuth(src) then return { success = false, error = 'Unauthorized' } end
payload = payload or {}
+ local caseId = tonumber(payload.caseId)
+ local reportId = tonumber(payload.reportId)
local evidence = payload.evidence or payload
if not evidence or not evidence.title then
return { success = false, error = 'Invalid evidence: title is required' }
end
- local caseId = nil
- if payload.caseId and tostring(payload.caseId) ~= '' then
- local n = tonumber(payload.caseId)
- if n then
- local row = MySQL.single.await('SELECT id FROM mdt_cases WHERE id = ?', { n })
- if not row then
- return { success = false, error = 'Case #' .. tostring(n) .. ' doesnt exist' }
- end
- caseId = n
- end
- end
-
- local reportId = nil
- if payload.reportId and tostring(payload.reportId) ~= '' then
- local n = tonumber(payload.reportId)
- if n then
- local row = MySQL.single.await('SELECT id FROM mdt_reports WHERE id = ?', { n })
- if not row then
- return { success = false, error = 'Report #' .. tostring(n) .. ' doesnt exist' }
- end
- reportId = n
- end
- end
-
local evidenceId = MySQL.insert.await([[
INSERT INTO mdt_evidence_items
(case_id, report_id, title, type, serial, notes, location, stash_id, stored, last_holder, created_by)
@@ -387,9 +303,41 @@ ps.registerCallback(resourceName .. ':server:addEvidenceImage', function(source,
return { success = false, error = 'Invalid image' }
end
- local url = image.url or image.data or ''
+ local url = image.url
local label = image.label or ''
+ -- Upload via FiveManage if base64 data is provided
+ if image.data and image.filename then
+ local contentType = image.contentType or ''
+ if Config.Uploads and Config.Uploads.AllowedEvidenceImageTypes then
+ local allowed = false
+ for _, mime in ipairs(Config.Uploads.AllowedEvidenceImageTypes) do
+ if mime == contentType then
+ allowed = true
+ break
+ end
+ end
+ if not allowed then
+ return { success = false, error = 'Unsupported image type' }
+ end
+ end
+
+ if not FiveManageUpload then
+ return { success = false, error = 'FiveManage upload not available' }
+ end
+
+ local dataUri = image.data
+ if type(dataUri) ~= 'string' or dataUri == '' then
+ return { success = false, error = 'Invalid image data' }
+ end
+
+ local uploadedUrl, uploadError = FiveManageUpload(dataUri, image.filename)
+ if not uploadedUrl then
+ return { success = false, error = 'Upload failed: ' .. (uploadError or 'Unknown error') }
+ end
+ url = uploadedUrl
+ end
+
if not url or url == '' then
return { success = false, error = 'Missing image URL' }
end
@@ -461,6 +409,7 @@ ps.registerCallback(resourceName .. ':server:linkEvidenceToCase', function(sourc
evidenceId = tonumber(evidenceId)
reportId = tonumber(reportId)
+ -- Support case number format like "CASE-2026-003" by looking up the numeric ID
local numericCaseId = tonumber(caseId)
if not numericCaseId and type(caseId) == 'string' and caseId ~= '' then
local row = MySQL.single.await('SELECT id FROM mdt_cases WHERE case_number = ? LIMIT 1', { caseId })
@@ -474,18 +423,9 @@ ps.registerCallback(resourceName .. ':server:linkEvidenceToCase', function(sourc
return { success = false, error = 'Invalid evidence or case' }
end
- local caseExists = MySQL.single.await('SELECT id FROM mdt_cases WHERE id = ?', { caseId })
- if not caseExists then
- return { success = false, error = 'Case #' .. tostring(caseId) .. ' does not exist' }
- end
-
MySQL.update.await('UPDATE mdt_evidence_items SET case_id = ? WHERE id = ?', { caseId, evidenceId })
-
if reportId then
- local reportExists = MySQL.single.await('SELECT id FROM mdt_reports WHERE id = ?', { reportId })
- if reportExists then
- linkReportToCase(reportId, caseId, ps.getIdentifier(src))
- end
+ linkReportToCase(reportId, caseId, ps.getIdentifier(src))
end
if ps.auditLog then
@@ -509,11 +449,6 @@ ps.registerCallback(resourceName .. ':server:linkEvidenceToReport', function(sou
return { success = false, error = 'Invalid evidence or report' }
end
- local reportExists = MySQL.single.await('SELECT id FROM mdt_reports WHERE id = ?', { reportId })
- if not reportExists then
- return { success = false, error = 'Report #' .. tostring(reportId) .. ' does not exist' }
- end
-
MySQL.update.await('UPDATE mdt_evidence_items SET report_id = ? WHERE id = ?', { reportId, evidenceId })
if ps.auditLog then
@@ -568,23 +503,11 @@ end)
RegisterNetEvent(resourceName .. ':server:openEvidenceStash', function(stashId)
local src = source
if not CheckAuth(src) then return end
- if not stashId or stashId == '' then return end
-
- -- qb-inventory
- if GetResourceState('qb-inventory') == 'started' then
- exports['qb-inventory']:OpenInventory(src, stashId, {
- maxweight = 4000000,
- slots = 500,
- })
- return
- end
- -- ox_inventory with forceOpenInventory
- if GetResourceState('ox_inventory') == 'started' then
-
- exports.ox_inventory:RegisterStash(stashId, stashId , 500 , 4000000)
+ if not stashId or stashId == '' then return end
- exports.ox_inventory:forceOpenInventory(src, 'stash', stashId)
- return
- end
-end)
\ No newline at end of file
+ exports['qb-inventory']:OpenInventory(src, stashId, {
+ maxweight = 4000000,
+ slots = 500,
+ })
+end)
diff --git a/server/backend/management.lua b/server/backend/management.lua
index 2b2c0da9..458d2f47 100644
--- a/server/backend/management.lua
+++ b/server/backend/management.lua
@@ -60,13 +60,13 @@ local function getAllPermissions()
'citizens_search', 'citizens_edit_licenses',
'bolos_view', 'bolos_create',
'vehicles_search', 'vehicles_edit_dmv',
- 'weapons_search', 'weapons_add',
+ 'weapons_search',
'cases_view', 'cases_create', 'cases_edit', 'cases_delete',
'evidence_view', 'evidence_create', 'evidence_transfer', 'evidence_upload',
'reports_view', 'reports_create', 'reports_delete',
'warrants_view', 'warrants_issue', 'warrants_close',
'charges_view', 'charges_edit',
- 'map_patrols_view', 'map_patrols_manage', 'map_patrols_edit', 'dispatch_attach', 'dispatch_route',
+ 'dispatch_attach', 'dispatch_route',
'cameras_view', 'bodycams_view',
'roster_manage_certifications', 'roster_manage_officers',
'management_permissions', 'management_bulletins', 'management_activity',
diff --git a/server/backend/officers.lua b/server/backend/officers.lua
index 7f247aa7..60bb9d60 100644
--- a/server/backend/officers.lua
+++ b/server/backend/officers.lua
@@ -1,7 +1,7 @@
local resourceName = tostring(GetCurrentResourceName())
local ok, QBCore = pcall(function() return exports['qb-core']:GetCoreObject() end)
if not ok then QBCore = nil end
-
+
-- Get player source ID by citizenId
ps.registerCallback(resourceName .. ':server:GetPlayerSourceId', function(source, targetCitizenId)
if not targetCitizenId then return nil end
@@ -12,90 +12,79 @@ ps.registerCallback(resourceName .. ':server:GetPlayerSourceId', function(source
end
return targetPlayer.source or targetPlayer.PlayerData.source
end)
-
+
-- Set Callsign
ps.registerCallback(resourceName .. ':server:setCallsign', function(source, payload)
local src = source
if not CheckAuth(src) then return { success = false, message = 'Unauthorized' } end
+
payload = payload or {}
local cid = payload.citizenid or payload.cid
local newCallsign = payload.callsign or payload.newcallsign
-
+
if not cid or not newCallsign then
return { success = false, message = 'Missing citizen ID or callsign' }
end
+
if not QBCore then return { success = false, message = 'Core framework not available' } end
local Player = QBCore.Functions.GetPlayerByCitizenId(cid)
if Player then
Player.Functions.SetMetaData('callsign', newCallsign)
TriggerClientEvent(resourceName .. ':client:updateCallsign', Player.PlayerData.source, newCallsign)
-
+
MySQL.update.await('UPDATE mdt_profiles SET callsign = ? WHERE citizenid = ?', { newCallsign, cid })
-
+
if ps.auditLog then
ps.auditLog(src, 'callsign_changed', 'officer', cid, { callsign = newCallsign })
end
-
+
return { success = true, message = 'Callsign updated to ' .. newCallsign }
end
+
return { success = false, message = 'Player must be online to update callsign' }
end)
-
-ps.registerCallback(resourceName .. ':server:getCallsign', function(source, payload)
- if not CheckAuth(source) then return { callsign = '' } end
- local cid = payload.citizenid
- if not cid then return { callsign = '' } end
-
- if not QBCore then return { success = false, message = 'Core framework not available' } end
- local Player = QBCore.Functions.GetPlayerByCitizenId(cid)
- if Player then
- return { callsign = tostring(Player.PlayerData.metadata.callsign or '') }
- end
- local row = MySQL.single.await('SELECT callsign FROM mdt_profiles WHERE citizenid = ?', { cid })
- return { callsign = tostring(row and row.callsign or '') }
-end)
-
+
-- Set Radio Frequency
ps.registerCallback(resourceName .. ':server:setRadio', function(source, payload)
local src = source
if not CheckAuth(src) then return { success = false, message = 'Unauthorized' } end
-
+
payload = payload or {}
local cid = payload.citizenid or payload.cid
local newRadio = payload.radio or payload.newradio
-
+
if not cid or not newRadio then
return { success = false, message = 'Missing citizen ID or radio frequency' }
end
-
+
if not QBCore then return { success = false, message = 'Core framework not available' } end
local targetPlayer = QBCore.Functions.GetPlayerByCitizenId(cid)
if not targetPlayer then
return { success = false, message = 'Officer must be online' }
end
-
+
local targetSource = targetPlayer.PlayerData.source
-
+
local radio = targetPlayer.Functions.GetItemByName('radio')
if not radio then
return { success = false, message = targetPlayer.PlayerData.charinfo.firstname .. ' does not have a radio!' }
end
-
+
TriggerClientEvent(resourceName .. ':client:setRadio', targetSource, newRadio)
return { success = true, message = 'Radio set to ' .. newRadio }
end)
-
+
-- Get Unit Location (GPS to officer)
ps.registerCallback(resourceName .. ':server:getUnitLocation', function(source, cid)
if not CheckAuth(source) then return {} end
if not cid then return {} end
-
+
if not QBCore then return {} end
local Player = QBCore.Functions.GetPlayerByCitizenId(cid)
if Player then
local coords = GetEntityCoords(GetPlayerPed(Player.PlayerData.source))
return { x = coords.x, y = coords.y, z = coords.z }
end
-
+
return {}
-end)
\ No newline at end of file
+end)
diff --git a/server/backend/reports.lua b/server/backend/reports.lua
index c44a0e7b..13b66572 100644
--- a/server/backend/reports.lua
+++ b/server/backend/reports.lua
@@ -91,51 +91,36 @@ local function normalizeDateFilter(value)
end
local function buildReportFilterClause(filters)
- local clauses = {}
- local values = {}
- if not filters then
- return '', values
- end
-
- local function hasValue(value)
- if value == nil then return false end
- if json and value == json.null then return false end
- if type(value) == 'string' then return value:gsub('%s+', '') ~= '' end
- return true
- end
+ local clauses = {}
+ local values = {}
+ if not filters then
+ return '', values
+ end
- if hasValue(filters.search) then
- local likeQuery = '%' .. tostring(filters.search) .. '%'
- clauses[#clauses + 1] = [[
- (
- mr.title LIKE ?
- OR CAST(mr.id AS CHAR) LIKE ?
- OR mr.authorplaintext LIKE ?
- OR mr.type LIKE ?
- OR EXISTS (
- SELECT 1 FROM mdt_reports_tags mrt2
- WHERE mrt2.reportid = mr.id AND mrt2.tag LIKE ?
- )
- )
- ]]
- values[#values + 1] = likeQuery
- values[#values + 1] = likeQuery
- values[#values + 1] = likeQuery
- values[#values + 1] = likeQuery
- values[#values + 1] = likeQuery
- end
+ local function hasValue(value)
+ if value == nil then
+ return false
+ end
+ if json and value == json.null then
+ return false
+ end
+ if type(value) == 'string' then
+ return value:gsub('%s+', '') ~= ''
+ end
+ return true
+ end
- if hasValue(filters.type) then
- clauses[#clauses + 1] = 'mr.type = ?'
- values[#values + 1] = filters.type
- end
+ if hasValue(filters.type) then
+ clauses[#clauses + 1] = 'mr.type = ?'
+ values[#values + 1] = filters.type
+ end
- if hasValue(filters.author) then
- local likeQuery = '%' .. tostring(filters.author) .. '%'
- clauses[#clauses + 1] = '(mr.authorplaintext LIKE ? OR mr.author LIKE ?)'
- values[#values + 1] = likeQuery
- values[#values + 1] = likeQuery
- end
+ if hasValue(filters.author) then
+ local likeQuery = '%' .. tostring(filters.author) .. '%'
+ clauses[#clauses + 1] = '(mr.authorplaintext LIKE ? OR mr.author LIKE ?)'
+ values[#values + 1] = likeQuery
+ values[#values + 1] = likeQuery
+ end
local startDate = normalizeDateFilter(filters.startDate)
if startDate then
@@ -219,67 +204,54 @@ end
ps.registerCallback(resourceName .. ':server:getReports', function(source, page, filters)
- local src = source
- if not CheckAuth(src) then return end
+ local src = source
+ if not CheckAuth(src) then return end
local identifier = ps.getIdentifier(src)
local job = ps.getJobName(src)
local jobType = getEffectiveJobType(src)
- local pageNumber = tonumber(page) or 1
- pageNumber = math.max(1, pageNumber)
- local limit = math.min(tonumber(filters and filters.limit) or 20, 100)
- local offset = (pageNumber - 1) * limit
-
- local filterClause, filterValues = buildReportFilterClause(filters)
- filterClause = filterClause or ''
-
- local accessClause = buildReportAccessClause()
- local baseParams = { jobType, jobType, jobType, identifier, job, jobType }
-
- local queryParams = {}
- for _, v in ipairs(baseParams) do queryParams[#queryParams + 1] = v end
- for _, v in ipairs(filterValues or {}) do queryParams[#queryParams + 1] = v end
-
- local countQuery = ([[
- SELECT COUNT(DISTINCT mr.id) AS total
- FROM mdt_reports AS mr
- LEFT JOIN mdt_reports_restrictions AS mrr ON mr.id = mrr.reportid
- WHERE %s%s
- ]]):format(accessClause, filterClause)
-
- local countRow = MySQL.single.await(countQuery, queryParams)
- local total = tonumber(countRow and countRow.total) or 0
-
- local reportsQuery = ([[
- SELECT
- mr.id,
- mr.id as reportId,
- mr.title,
- mr.type,
- mr.contentyjs,
- mr.contentplaintext,
- mr.author,
- mr.authorplaintext,
- mr.datecreated,
- mr.dateupdated,
- (SELECT mrt.tag FROM mdt_reports_tags mrt WHERE mrt.reportid = mr.id LIMIT 1) as tag,
- (SELECT COUNT(*) FROM mdt_reports_tags mrt WHERE mrt.reportid = mr.id) as tagCount
- FROM mdt_reports AS mr
- LEFT JOIN mdt_reports_restrictions AS mrr ON mr.id = mrr.reportid
- WHERE %s%s
- GROUP BY mr.id
- ORDER BY mr.id DESC
- LIMIT %d OFFSET %d
- ]]):format(accessClause, filterClause, limit, offset)
-
- local reportsParams = {}
- for _, v in ipairs(baseParams) do reportsParams[#reportsParams + 1] = v end
- for _, v in ipairs(filterValues or {}) do reportsParams[#reportsParams + 1] = v end
-
- local reports = MySQL.query.await(reportsQuery, reportsParams)
-
- return { reports = reports or {}, total = total }
+ local pageNumber = tonumber(page) or 1
+ pageNumber = math.max(1, pageNumber)
+ local limit = 20
+ local offset = (pageNumber - 1) * limit
+
+ local filterClause, filterValues = buildReportFilterClause(filters)
+ filterClause = filterClause or ''
+
+ local reportsQuery = ([[
+ SELECT
+ mr.id,
+ mr.id as reportId,
+ mr.title,
+ mr.type,
+ mr.contentyjs,
+ mr.contentplaintext,
+ mr.author,
+ mr.authorplaintext,
+ mr.datecreated,
+ mr.dateupdated,
+ (SELECT mrt.tag FROM mdt_reports_tags mrt WHERE mrt.reportid = mr.id LIMIT 1) as tag,
+ (SELECT COUNT(*) FROM mdt_reports_tags mrt WHERE mrt.reportid = mr.id) as tagCount
+ FROM
+ mdt_reports AS mr
+ LEFT JOIN
+ mdt_reports_restrictions AS mrr ON mr.id = mrr.reportid
+ WHERE
+ %s%s
+ GROUP BY
+ mr.id
+ ORDER BY
+ mr.datecreated DESC
+ LIMIT %d
+ OFFSET %d
+ ]]):format(buildReportAccessClause(), filterClause, limit, offset)
+ local params = { jobType, jobType, jobType, identifier, job, jobType }
+ for _, value in ipairs(filterValues or {}) do
+ params[#params + 1] = value
+ end
+ local reports = MySQL.query.await(reportsQuery, params)
+ return reports
end)
ps.registerCallback(resourceName..':server:getReport', function(source, reportid)
@@ -318,16 +290,10 @@ ps.registerCallback(resourceName..':server:getReport', function(source, reportid
) FROM mdt_reports_charges mrc WHERE mrc.reportid = mr.id) as charges,
(SELECT JSON_ARRAYAGG(
JSON_OBJECT(
- 'title', mre.title,
'type', mre.type,
'content', mre.content,
'note', mre.note,
- 'stored', mre.stored,
- 'images', CASE
- WHEN mre.images IS NOT NULL AND mre.images != ''
- THEN JSON_EXTRACT(mre.images, '$')
- ELSE JSON_ARRAY()
- END
+ 'stored', mre.stored
)
) FROM mdt_reports_evidence mre WHERE mre.reportid = mr.id) as evidence,
(SELECT JSON_ARRAYAGG(
@@ -389,8 +355,7 @@ ps.registerCallback(resourceName..':server:getReport', function(source, reportid
' ',
JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.lastname'))
) as fullname,
- mp.profilepicture as image,
- JSON_UNQUOTE(JSON_EXTRACT(p.metadata, '$.fingerprint')) as fingerprint
+ mp.profilepicture as image
FROM players p
LEFT JOIN mdt_profiles mp ON mp.citizenid COLLATE utf8mb4_general_ci = p.citizenid COLLATE utf8mb4_general_ci
WHERE p.citizenid COLLATE utf8mb4_general_ci IN (%s)
@@ -398,12 +363,10 @@ ps.registerCallback(resourceName..':server:getReport', function(source, reportid
local lookupRows = MySQL.query.await(lookupQuery, cidList)
local cidInfo = {}
- for _, row in ipairs(lookupRows) do
- cidInfo[row.citizenid] = {
- name = row.fullname,
- image = row.image,
- fingerprint = (row.fingerprint and row.fingerprint ~= 'null') and row.fingerprint or nil
- }
+ if lookupRows then
+ for _, row in ipairs(lookupRows) do
+ cidInfo[row.citizenid] = { name = row.fullname, image = row.image }
+ end
end
for _, entry in ipairs(involved) do
@@ -411,7 +374,6 @@ ps.registerCallback(resourceName..':server:getReport', function(source, reportid
local info = cidInfo[entry.citizenid]
entry.name = info.name or entry.name
entry.image = info.image
- entry.fingerprint = info.fingerprint
end
end
end
@@ -551,52 +513,43 @@ ps.registerCallback(resourceName .. ':server:searchVehiclesForReport', function(
return {}
end
- local vehicleShared = nil
- local ok, core = pcall(function() return exports['qb-core']:GetCoreObject() end)
- if ok and core and core.Shared and core.Shared.Vehicles then
- vehicleShared = core.Shared.Vehicles
- end
-
- local likeQuery = '%' .. query:upper() .. '%'
- local likeQueryRaw = '%' .. query .. '%'
+ local likeQuery = '%' .. query .. '%'
local rows = MySQL.query.await([[
SELECT
pv.plate,
pv.vehicle,
pv.citizenid,
- COALESCE(
- NULLIF(
- CONCAT(
- COALESCE(JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.firstname')), ''),
- ' ',
- COALESCE(JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.lastname')), '')
- ),
- ' '
- ),
- 'Unknown'
+ CONCAT(
+ JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.firstname')),
+ ' ',
+ JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.lastname'))
) as owner_name
FROM player_vehicles pv
LEFT JOIN players p ON p.citizenid COLLATE utf8mb4_general_ci = pv.citizenid COLLATE utf8mb4_general_ci
WHERE (
- UPPER(REPLACE(pv.plate, ' ', '')) LIKE REPLACE(?, ' ', '')
- OR UPPER(pv.plate) LIKE ?
+ pv.plate LIKE ?
OR CONCAT(
- COALESCE(JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.firstname')), ''),
+ JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.firstname')),
' ',
- COALESCE(JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.lastname')), '')
+ JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.lastname'))
) LIKE ?
- OR p.citizenid LIKE ?
)
- ORDER BY pv.plate ASC
LIMIT 25
- ]], { likeQuery, likeQuery, likeQueryRaw, likeQueryRaw })
+ ]], { likeQuery, likeQuery })
local results = {}
for _, row in ipairs(rows or {}) do
- local vehicleData = vehicleShared and vehicleShared[row.vehicle] or nil
+ local vehicleData = nil
+ local ok, core = pcall(function()
+ return exports['qb-core']:GetCoreObject()
+ end)
+ if ok and core and core.Shared and core.Shared.Vehicles then
+ vehicleData = core.Shared.Vehicles[row.vehicle]
+ end
+
table.insert(results, {
- plate = row.plate and string.upper(row.plate):gsub('%s+', '') or 'UNKNOWN',
+ plate = row.plate and string.upper(row.plate) or 'UNKNOWN',
vehicle_label = vehicleData and vehicleData.name or row.vehicle or 'Unknown',
owner_name = row.owner_name or 'Unknown',
owner_citizenid = row.citizenid or nil,
@@ -747,21 +700,10 @@ ps.registerCallback(resourceName..':server:saveReport', function(source, reportD
if reportData.evidence and #reportData.evidence > 0 then
for _, evidence in ipairs(reportData.evidence) do
- local imagesJson = nil
- if evidence.images and type(evidence.images) == 'table' and #evidence.images > 0 then
- imagesJson = json.encode(evidence.images)
- end
table.insert(attachmentQueries, {
- query = "INSERT INTO mdt_reports_evidence (reportid, title, type, content, note, stored, images) VALUES (?, ?, ?, ?, ?, ?, ?)",
- values = {
- reportId,
- evidence.title or '',
- evidence.type or 'Physical',
- evidence.content or '',
- evidence.note or '',
- evidence.stored or 0,
- imagesJson
- }
+ query =
+ "INSERT INTO mdt_reports_evidence (reportid, type, content, note, stored) VALUES (?, ?, ?, ?, ?)",
+ values = { reportId, evidence.type, evidence.content, evidence.note, evidence.stored or 0 }
})
end
end
@@ -1135,4 +1077,4 @@ ps.registerCallback(resourceName .. ':server:getReportsByPlate', function(source
]], { plate })
return rows or {}
-end)
\ No newline at end of file
+end)
diff --git a/server/backend/settings.lua b/server/backend/settings.lua
index 7378e074..d8b56a40 100644
--- a/server/backend/settings.lua
+++ b/server/backend/settings.lua
@@ -44,16 +44,6 @@ local ACTION_CATEGORIES = {
icu_deleted = 'icu',
camera_viewed = 'cameras',
bodycam_viewed = 'bodycams',
- -- ── Patrol management ────────────────────────────────────────────────────
- patrol_created = 'patrols',
- patrol_deleted = 'patrols',
- patrol_renamed = 'patrols',
- patrol_zone_created = 'patrols',
- patrol_zone_updated = 'patrols',
- patrol_zone_cleared = 'patrols',
- patrol_officer_assigned = 'patrols',
- patrol_officer_removed = 'patrols',
- patrols_reordered = 'patrols',
}
-- Cache for tracking config (loaded once, updated on save)
@@ -77,7 +67,6 @@ local function getDefaultTracking()
icu = true,
cameras = true,
bodycams = true,
- patrols = true, -- ← new
}
end
@@ -421,4 +410,4 @@ ps.registerCallback(resourceName .. ':server:saveColorConfig', function(source,
end
return { success = true }
-end)
\ No newline at end of file
+end)
diff --git a/server/backend/tracking.lua b/server/backend/tracking.lua
index 2ced8155..a70a7c4b 100644
--- a/server/backend/tracking.lua
+++ b/server/backend/tracking.lua
@@ -1,729 +1,162 @@
--- ============================================================================
--- server_patrols.lua — MDT patrol & live tracking (server side)
--- ----------------------------------------------------------------------------
--- Responsibilities:
--- * Live officer/vehicle tracking served to the map NUI (getTracking).
--- * Patrol CRUD + ordering + zone storage, persisted in `mdt_patrols`.
--- * Audit logging of every patrol mutation via ps.auditLog.
---
--- Performance notes for future devs:
--- * getTracking is polled by EVERY open MDT (~every 4.5s in the NUI). The
--- result is cached for TRACKING_CACHE_TTL ms so the heavy player/entity
--- scan runs at most once per TTL no matter how many MDTs are open. Never
--- call getAllTrackers() directly from the callback — use getTrackingSnapshot().
--- * vehicleCache holds recently-parked police vehicles. Entries self-expire
--- after VEHICLE_CACHE_TTL so ghost markers disappear even when the
--- 'entityRemoved' event never fires (common when entities get culled).
--- Cache-served vehicles carry `cached = true` so the NUI can dim them.
--- * DB writes for frequent mutations (assign/remove) are debounced
--- (SAVE_DEBOUNCE_MS) and coalesced per patrol. Structural changes
--- (create/rename/zone/delete) are written immediately so a crash can't
--- lose them.
--- * broadcastPatrols coalesces plain broadcasts to one per frame. Action
--- broadcasts (assigned/removed) bypass coalescing because the NUI needs
--- the flash hint immediately.
---
--- Set MDT_DEBUG = true for verbose console logging while developing.
--- ============================================================================
-
local resourceName = tostring(GetCurrentResourceName())
--- ─── Tunables ───────────────────────────────────────────────────────────────
-local MDT_DEBUG = false -- verbose dev logging; KEEP FALSE on production (log spam)
-local TRACKING_CACHE_TTL = 2000 -- ms — shared tracking snapshot lifetime
-local VEHICLE_CACHE_TTL = 600000 -- ms — parked-vehicle cache entry lifetime (10 min)
-local SAVE_DEBOUNCE_MS = 1000 -- ms — coalesce window for debounced patrol saves
-
--- ─── State ──────────────────────────────────────────────────────────────────
-local vehicleCache = {} -- [plate] = { plate, coords, heading, _ts }
-local cacheVehicleCooldowns = {} -- [src] = os.time() of last cacheVehicle event
-local patrols = {} -- [id] = patrol
-local patrolOrder = {} -- ordered list of patrol ids
-local trackingCache = { vehicles = {}, bodycams = {}, ts = 0 }
-
--- ─── Tiny helpers ─────────────────────────────────────────────────────────
+local function getOfficerTrackers()
+ local officers = {}
--- Gated dev logger. No-op unless MDT_DEBUG is on, so it's free in production.
-local function dbg(...)
- if MDT_DEBUG then print('[MDT]', ...) end
-end
-
--- Cache the QBCore object once instead of crossing the export boundary on
--- every call. Returns nil on non-QB frameworks (then the `ps`/ESX path is used).
-local _qbCore
-local function getQBCore()
- if _qbCore then return _qbCore end
if exports['qb-core'] then
- _qbCore = exports['qb-core']:GetCoreObject()
- end
- return _qbCore
-end
-
--- ─── Validation ─────────────────────────────────────────────────────────────
-
-local function isValidPatrolId(id)
- return type(id) == "string" and #id > 0 and #id <= 64
-end
-local function isValidColor(color)
- return type(color) == "string" and color:match("^#%x%x%x%x%x%x$")
-end
-local function isValidName(name)
- return type(name) == "string" and #name > 0 and #name <= 64
-end
-local function isValidCitizenId(cid)
- return type(cid) == "string" and #cid > 0 and #cid <= 64
-end
-
--- Validate zone_points: array of {x, y} pairs, max 64 points
-local function isValidZonePoints(points)
- if points == nil then return true end -- nil = no zone, valid
- if type(points) ~= "table" then return false end
- if #points > 64 then return false end
- for _, pt in ipairs(points) do
- if type(pt) ~= "table" then return false end
- if type(pt.x) ~= "number" or type(pt.y) ~= "number" then return false end
- if pt.x < -10000 or pt.x > 10000 or pt.y < -10000 or pt.y > 10000 then return false end
- end
- return true
-end
-
--- ─── Audit helpers ────────────────────────────────────────────────────────
-
-local function getOfficerInfo(src)
- local QBCore = getQBCore()
- if QBCore then
- local player = QBCore.Functions.GetPlayer(src)
- if player then
- local d = player.PlayerData
- return {
- citizenid = d.citizenid,
- name = d.charinfo.firstname .. ' ' .. d.charinfo.lastname,
- callsign = d.metadata and d.metadata.callsign or nil,
- rank = d.job and d.job.grade and d.job.grade.name or nil,
- job = d.job and d.job.name or nil,
- }
+ local QBCore = exports['qb-core']:GetCoreObject()
+ local players = QBCore.Functions.GetQBPlayers() or {}
+ for _, player in pairs(players) do
+ local data = player.PlayerData
+ if data and data.job and data.job.onduty then
+ if IsPoliceJob(data.job.name, data.job.type) then
+ local src = data.source
+ local ped = GetPlayerPed(src)
+ if ped and ped ~= 0 then
+ local coords = GetEntityCoords(ped)
+ local coordsTable = { x = coords.x, y = coords.y, z = coords.z }
+ local heading = GetEntityHeading(ped)
+ officers[#officers + 1] = {
+ citizenid = data.citizenid,
+ name = (data.charinfo.firstname .. ' ' .. data.charinfo.lastname),
+ callsign = data.metadata and data.metadata.callsign or nil,
+ rank = data.job.grade and data.job.grade.name or 'Officer',
+ coords = coordsTable,
+ heading = heading,
+ }
+ end
+ end
+ end
end
- elseif ps and ps.getIdentifier then
- local name = (ps.getPlayerName and ps.getPlayerName(src)) or GetPlayerName(src) or 'Unknown'
- return {
- citizenid = ps.getIdentifier(src),
- name = name,
- callsign = ps.getMetadata and ps.getMetadata(src, 'callsign') or nil,
- rank = ps.getJobGradeName and ps.getJobGradeName(src) or nil,
- job = ps.getJobName and ps.getJobName(src) or nil,
- }
- end
- return { name = GetPlayerName(src) or ('Player #' .. src) }
-end
-
--- Resolve a citizenid -> "Firstname Lastname" without scanning every online
--- player. GetPlayerByCitizenId is a direct lookup on QBCore.
-local function getNameByCitizenId(citizenId)
- local QBCore = getQBCore()
- if QBCore and QBCore.Functions.GetPlayerByCitizenId then
- local p = QBCore.Functions.GetPlayerByCitizenId(citizenId)
- if p and p.PlayerData and p.PlayerData.charinfo then
- return p.PlayerData.charinfo.firstname .. ' ' .. p.PlayerData.charinfo.lastname
+ return officers
+ end
+
+ if ps and ps.getAllPlayers then
+ local players = ps.getAllPlayers() or {}
+ for _, playerId in pairs(players) do
+ if ps.getJobDuty and ps.getJobDuty(playerId) then
+ local jobName = ps.getJobName and ps.getJobName(playerId) or nil
+ local jobType = ps.getJobType and ps.getJobType(playerId) or nil
+ if IsPoliceJob(jobName, jobType) then
+ local ped = GetPlayerPed(playerId)
+ if ped and ped ~= 0 then
+ local coords = GetEntityCoords(ped)
+ local coordsTable = { x = coords.x, y = coords.y, z = coords.z }
+ local heading = GetEntityHeading(ped)
+ officers[#officers + 1] = {
+ citizenid = ps.getIdentifier and ps.getIdentifier(playerId) or nil,
+ name = ps.getPlayerName and ps.getPlayerName(playerId) or GetPlayerName(playerId) or 'Unknown',
+ callsign = ps.getMetadata and ps.getMetadata(playerId, 'callsign') or nil,
+ rank = ps.getJobGradeName and ps.getJobGradeName(playerId) or 'Officer',
+ coords = coordsTable,
+ heading = heading,
+ }
+ end
+ end
+ end
end
end
- return citizenId
-end
-
-local function auditPatrol(src, action, patrolId, extra)
- if not ps.auditLog then return end
- local officer = getOfficerInfo(src)
- local data = {
- officer_name = officer.name,
- officer_callsign = officer.callsign,
- officer_rank = officer.rank,
- officer_id = officer.citizenid,
- }
- if extra then
- for k, v in pairs(extra) do data[k] = v end
- end
- ps.auditLog(src, action, 'mdt_patrol', patrolId or 'none', data)
-end
-
--- ─── DB ─────────────────────────────────────────────────────────────────────
--- Immediate write. Used directly for structural changes; the debounced
--- savePatrol() below funnels frequent membership writes through this.
-local function savePatrolNow(patrol)
- if not patrol then return end
- MySQL.insert(
- "INSERT INTO mdt_patrols (id, name, color, member_ids, sort_order, zone_points) VALUES (?, ?, ?, ?, ?, ?) " ..
- "ON DUPLICATE KEY UPDATE name = VALUES(name), color = VALUES(color), " ..
- "member_ids = VALUES(member_ids), sort_order = VALUES(sort_order), zone_points = VALUES(zone_points)",
- {
- patrol.id,
- patrol.name,
- patrol.color,
- json.encode(patrol.memberIds),
- patrol.sortOrder or 0,
- patrol.zonePoints and json.encode(patrol.zonePoints) or nil,
- }
- )
+ return officers
end
--- Debounced save: coalesces rapid writes for the same patrol into a single DB
--- write after SAVE_DEBOUNCE_MS. Heavy assign/remove churn no longer hits the DB
--- on every click. Always references the live patrol table, so the flush writes
--- the latest state. delete cancels any pending save (see deletePatrol).
-local pendingSaves = {} -- [id] = patrol
-local saveTimerArmed = false
-
-local function flushSaves()
- saveTimerArmed = false
- for id, patrol in pairs(pendingSaves) do
- pendingSaves[id] = nil
- savePatrolNow(patrol)
- end
-end
-
-local function savePatrol(patrol)
- if not patrol then return end
- pendingSaves[patrol.id] = patrol
- if not saveTimerArmed then
- saveTimerArmed = true
- SetTimeout(SAVE_DEBOUNCE_MS, flushSaves)
- end
-end
-
-local function deletePatrolFromDB(id)
- MySQL.execute("DELETE FROM mdt_patrols WHERE id = ?", { id })
-end
-
-local function saveOrder()
- for i, id in ipairs(patrolOrder) do
- if patrols[id] then
- patrols[id].sortOrder = i
- MySQL.execute("UPDATE mdt_patrols SET sort_order = ? WHERE id = ?", { i, id })
- end
- end
-end
-
--- ─── Broadcast ──────────────────────────────────────────────────────────────
+local function getVehicleTrackers()
+ local vehicles = {}
+ local seen = {}
-local function doBroadcast(action, citizenid)
- local ordered = {}
- for _, id in ipairs(patrolOrder) do
- if patrols[id] then
- ordered[#ordered + 1] = patrols[id]
+ if exports['qb-core'] then
+ local QBCore = exports['qb-core']:GetCoreObject()
+ local players = QBCore.Functions.GetQBPlayers() or {}
+ for _, player in pairs(players) do
+ local data = player.PlayerData
+ if data and data.job and data.job.onduty and IsPoliceJob(data.job.name, data.job.type) then
+ local ped = GetPlayerPed(data.source)
+ if ped and ped ~= 0 then
+ local veh = GetVehiclePedIsIn(ped, false)
+ if veh and veh ~= 0 and not seen[veh] then
+ seen[veh] = true
+ local coords = GetEntityCoords(veh)
+ local coordsTable = { x = coords.x, y = coords.y, z = coords.z }
+ local heading = GetEntityHeading(veh)
+ local plate = GetVehicleNumberPlateText(veh)
+ vehicles[#vehicles + 1] = {
+ plate = plate,
+ coords = coordsTable,
+ heading = heading,
+ }
+ end
+ end
+ end
end
end
- TriggerClientEvent(resourceName .. ":client:syncPatrols", -1, ordered, action, citizenid)
-end
--- Plain broadcasts (create/delete/rename/reorder/zone/disconnect) are coalesced
--- to one per frame so a burst of mutations doesn't fan the full list out to
--- every client multiple times. Action broadcasts (assigned/removed) carry a
--- flash hint the NUI needs, so they go out immediately.
-local broadcastScheduled = false
-local function broadcastPatrols(action, citizenid)
- if action then
- doBroadcast(action, citizenid)
- return
- end
- if broadcastScheduled then return end
- broadcastScheduled = true
- SetTimeout(0, function()
- broadcastScheduled = false
- doBroadcast()
- end)
+ return vehicles
end
--- ─── Tracking ─────────────────────────────────────────────────────────────
-
--- HEAVY: scans all on-duty police players + their vehicles. Do not call this
--- per client request — it's wrapped by getTrackingSnapshot() which caches it.
-local function getAllTrackers()
- local vehicles = {}
+local function getBodycamTrackers()
local bodycams = {}
- local seenVehicles = {}
- local now = GetGameTimer()
- local QBCore = getQBCore()
- if QBCore then
+ if exports['qb-core'] then
+ local QBCore = exports['qb-core']:GetCoreObject()
local players = QBCore.Functions.GetQBPlayers() or {}
-
for _, player in pairs(players) do
local data = player.PlayerData
- if not data or not data.job or not data.job.onduty then goto continue end
- if not IsPoliceJob(data.job.name, data.job.type) then goto continue end
-
- local src = data.source
- local ped = GetPlayerPed(src)
- if not ped or ped == 0 then goto continue end
-
- local coords = GetEntityCoords(ped)
- local veh = GetVehiclePedIsIn(ped, false)
- bodycams[#bodycams + 1] = {
- citizenid = data.citizenid,
- name = data.charinfo.firstname .. ' ' .. data.charinfo.lastname,
- callsign = data.metadata and data.metadata.callsign or nil,
- rank = data.job.grade and data.job.grade.name or 'Officer',
- coords = { x = coords.x, y = coords.y, z = coords.z },
- heading = GetEntityHeading(ped),
- inVehicle = veh and veh ~= 0,
- }
-
- if veh and veh ~= 0 and not seenVehicles[veh] then
- seenVehicles[veh] = true
- local vCoords = GetEntityCoords(veh)
- local vHeading = GetEntityHeading(veh)
- local plate = GetVehicleNumberPlateText(veh):gsub('%s+', '')
- local coordsTbl = { x = vCoords.x, y = vCoords.y, z = vCoords.z }
- vehicles[#vehicles + 1] = { plate = plate, coords = coordsTbl, heading = vHeading }
- vehicleCache[plate] = { plate = plate, coords = coordsTbl, heading = vHeading, _ts = now }
+ if data and data.job and data.job.onduty then
+ if IsPoliceJob(data.job.name, data.job.type) then
+ local ped = GetPlayerPed(data.source)
+ if ped and ped ~= 0 then
+ local coords = GetEntityCoords(ped)
+ local coordsTable = { x = coords.x, y = coords.y, z = coords.z }
+ local heading = GetEntityHeading(ped)
+ bodycams[#bodycams + 1] = {
+ citizenid = data.citizenid,
+ name = (data.charinfo.firstname .. ' ' .. data.charinfo.lastname),
+ callsign = data.metadata and data.metadata.callsign or nil,
+ coords = coordsTable,
+ heading = heading,
+ }
+ end
+ end
end
- ::continue::
end
-
- elseif ps and ps.getAllPlayers then
- local playerList = ps.getAllPlayers() or {}
- for _, playerId in pairs(playerList) do
- if not (ps.getJobDuty and ps.getJobDuty(playerId)) then goto continue end
- local jobName = ps.getJobName and ps.getJobName(playerId) or nil
- local jobType = ps.getJobType and ps.getJobType(playerId) or nil
- if not IsPoliceJob(jobName, jobType) then goto continue end
-
- local ped = GetPlayerPed(playerId)
- if not ped or ped == 0 then goto continue end
-
- local coords = GetEntityCoords(ped)
- local veh = GetVehiclePedIsIn(ped, false)
- bodycams[#bodycams + 1] = {
- citizenid = ps.getIdentifier and ps.getIdentifier(playerId) or nil,
- name = (ps.getPlayerName and ps.getPlayerName(playerId)) or GetPlayerName(playerId) or 'Unknown',
- callsign = ps.getMetadata and ps.getMetadata(playerId, 'callsign') or nil,
- rank = ps.getJobGradeName and ps.getJobGradeName(playerId) or 'Officer',
- coords = { x = coords.x, y = coords.y, z = coords.z },
- heading = GetEntityHeading(ped),
- inVehicle = veh and veh ~= 0,
- }
-
- if veh and veh ~= 0 and not seenVehicles[veh] then
- seenVehicles[veh] = true
- local vCoords = GetEntityCoords(veh)
- local vHeading = GetEntityHeading(veh)
- local plate = GetVehicleNumberPlateText(veh):gsub('%s+', '')
- local coordsTbl = { x = vCoords.x, y = vCoords.y, z = vCoords.z }
- vehicles[#vehicles + 1] = { plate = plate, coords = coordsTbl, heading = vHeading }
- vehicleCache[plate] = { plate = plate, coords = coordsTbl, heading = vHeading, _ts = now }
+ return bodycams
+ end
+
+ if ps and ps.getAllPlayers then
+ local players = ps.getAllPlayers() or {}
+ for _, playerId in pairs(players) do
+ if ps.getJobDuty and ps.getJobDuty(playerId) then
+ local jobName = ps.getJobName and ps.getJobName(playerId) or nil
+ local jobType = ps.getJobType and ps.getJobType(playerId) or nil
+ if IsPoliceJob(jobName, jobType) then
+ local ped = GetPlayerPed(playerId)
+ if ped and ped ~= 0 then
+ local coords = GetEntityCoords(ped)
+ local coordsTable = { x = coords.x, y = coords.y, z = coords.z }
+ local heading = GetEntityHeading(ped)
+ bodycams[#bodycams + 1] = {
+ citizenid = ps.getIdentifier and ps.getIdentifier(playerId) or nil,
+ name = ps.getPlayerName and ps.getPlayerName(playerId) or GetPlayerName(playerId) or 'Unknown',
+ callsign = ps.getMetadata and ps.getMetadata(playerId, 'callsign') or nil,
+ coords = coordsTable,
+ heading = heading,
+ }
+ end
+ end
end
- ::continue::
- end
- end
-
- -- Merge in cached (parked / recently-left) police vehicles that weren't
- -- seen live this pass. Stale entries are pruned here so ghost markers vanish
- -- after VEHICLE_CACHE_TTL even if 'entityRemoved' never fires. These carry
- -- `cached = true` so the NUI can render them dimmed ("last known position").
- local seenPlates = {}
- for _, v in ipairs(vehicles) do seenPlates[v.plate] = true end
- for plate, cacheData in pairs(vehicleCache) do
- if (now - (cacheData._ts or 0)) > VEHICLE_CACHE_TTL then
- vehicleCache[plate] = nil
- elseif not seenPlates[plate] then
- vehicles[#vehicles + 1] = {
- plate = cacheData.plate,
- coords = cacheData.coords,
- heading = cacheData.heading,
- cached = true,
- }
- seenPlates[plate] = true
end
end
- return vehicles, bodycams
-end
-
--- Returns a shared, throttled snapshot. With N MDTs open the expensive scan
--- runs at most once per TRACKING_CACHE_TTL instead of N times per poll cycle.
-local function getTrackingSnapshot()
- local now = GetGameTimer()
- if trackingCache.ts ~= 0 and (now - trackingCache.ts) < TRACKING_CACHE_TTL then
- return trackingCache
- end
- local vehicles, bodycams = getAllTrackers()
- trackingCache.vehicles = vehicles
- trackingCache.bodycams = bodycams
- trackingCache.ts = now
- return trackingCache
+ return bodycams
end
ps.registerCallback(resourceName .. ':server:getTracking', function(source)
- if not CheckAuth(source) then
- return { vehicles = {}, bodycams = {} }
- end
- local snap = getTrackingSnapshot()
- return { vehicles = snap.vehicles, bodycams = snap.bodycams }
-end)
-
-RegisterNetEvent(resourceName .. ':server:cacheVehicle', function(plate, coords, heading)
local src = source
- -- Defense in depth: only authorised, on-duty police may inject cache markers.
- if not CheckAuth(src) then return end
- if type(plate) ~= 'string' or #plate == 0 or #plate > 8 then return end
- if type(coords) ~= 'table' or type(coords.x) ~= 'number' or type(coords.y) ~= 'number' or type(coords.z) ~= 'number' then return end
- if type(heading) ~= 'number' or heading < 0 or heading > 360 then return end
- if coords.x < -4000 or coords.x > 4000 or coords.y < -4000 or coords.y > 8000 then return end
-
- local nowSec = os.time()
- if cacheVehicleCooldowns[src] and nowSec - cacheVehicleCooldowns[src] < 5 then return end
- cacheVehicleCooldowns[src] = nowSec
+ if not CheckAuth(src) then return { officers = {}, vehicles = {}, bodycams = {} } end
- -- Per-framework duty/police check (QB path and ps/ESX path).
- local QBCore = getQBCore()
- if QBCore then
- local player = QBCore.Functions.GetPlayer(src)
- if not player then return end
- local job = player.PlayerData.job
- if not job or not job.onduty or not IsPoliceJob(job.name, job.type) then return end
- elseif ps and ps.getJobName then
- if not (ps.getJobDuty and ps.getJobDuty(src)) then return end
- local jobName = ps.getJobName(src)
- local jobType = ps.getJobType and ps.getJobType(src) or nil
- if not IsPoliceJob(jobName, jobType) then return end
- end
-
- vehicleCache[plate] = {
- plate = plate,
- coords = { x = coords.x, y = coords.y, z = coords.z },
- heading = heading,
- _ts = GetGameTimer(),
+ return {
+ officers = getOfficerTrackers(),
+ vehicles = getVehicleTrackers(),
+ bodycams = getBodycamTrackers(),
}
end)
-
-RegisterNetEvent('baseevents:leftVehicle', function(vehicle, seat, model, netId)
- local src = source
- if not CheckAuth(src) then return end
- local veh = NetworkGetEntityFromNetworkId(netId)
- if not veh or veh == 0 then return end
- local coords = GetEntityCoords(veh)
- local heading = GetEntityHeading(veh)
- local plate = GetVehicleNumberPlateText(veh):gsub('%s+', '')
- if not plate or #plate == 0 then return end
- TriggerClientEvent(resourceName .. ':client:checkVehicleClass', src, netId, plate,
- { x = coords.x, y = coords.y, z = coords.z }, heading)
-end)
-
-AddEventHandler('entityRemoved', function(entity)
- if GetEntityType(entity) ~= 2 then return end
- local plate = GetVehicleNumberPlateText(entity)
- if not plate or plate == '' then return end
- plate = plate:gsub('%s+', '')
- vehicleCache[plate] = nil
-end)
-
--- ─── Patrols ──────────────────────────────────────────────────────────────
-
-ps.registerCallback(resourceName .. ":server:getPatrols", function(source)
- local src = source
- if not CheckAuth(src) then return {} end
- local ordered = {}
- for _, id in ipairs(patrolOrder) do
- if patrols[id] then
- ordered[#ordered + 1] = patrols[id]
- end
- end
- return ordered
-end)
-
-RegisterNetEvent(resourceName .. ":server:createPatrol", function(id, name, color)
- local src = source
- if not CheckAuth(src) then return end
- if not isValidPatrolId(id) or not isValidName(name) or not isValidColor(color) then return end
- if patrols[id] then return end
-
- local sortOrder = #patrolOrder + 1
- patrols[id] = { id = id, name = name, color = color, memberIds = {}, zonePoints = nil, sortOrder = sortOrder }
- patrolOrder[#patrolOrder + 1] = id
- broadcastPatrols()
- savePatrolNow(patrols[id]) -- structural change → write immediately
- dbg(('patrol created: "%s" (%s)'):format(name, id))
- auditPatrol(src, 'patrol_created', id, {
- patrol_name = name,
- patrol_color = color,
- action_label = ('Created patrol "%s"'):format(name),
- })
-end)
-
-RegisterNetEvent(resourceName .. ":server:deletePatrol", function(id)
- local src = source
- if not CheckAuth(src) then return end
- if not isValidPatrolId(id) then return end
- if not patrols[id] then return end
-
- -- Capture the name BEFORE removing the entry (previous code read it after
- -- nil-ing patrols[id], so the audit log always showed the raw id).
- local deletedName = patrols[id].name or id
-
- pendingSaves[id] = nil -- cancel any debounced save so it can't re-insert the row
- patrols[id] = nil
- for i = #patrolOrder, 1, -1 do
- if patrolOrder[i] == id then
- table.remove(patrolOrder, i)
- break
- end
- end
- deletePatrolFromDB(id)
- saveOrder()
- broadcastPatrols()
- dbg(('patrol deleted: "%s" (%s)'):format(deletedName, id))
- auditPatrol(src, 'patrol_deleted', id, {
- patrol_name = deletedName,
- action_label = ('Deleted patrol "%s"'):format(deletedName),
- })
-end)
-
-RegisterNetEvent(resourceName .. ":server:renamePatrol", function(id, newName)
- local src = source
- if not CheckAuth(src) then return end
- if not isValidPatrolId(id) or not isValidName(newName) then return end
- if not patrols[id] then return end
-
- local oldName = patrols[id].name
- patrols[id].name = newName
- broadcastPatrols()
- savePatrolNow(patrols[id]) -- structural change → write immediately
- dbg(('patrol renamed: "%s" -> "%s"'):format(oldName, newName))
- auditPatrol(src, 'patrol_renamed', id, {
- patrol_old_name = oldName,
- patrol_new_name = newName,
- action_label = ('Renamed patrol "%s" → "%s"'):format(oldName, newName),
- })
-end)
-
--- ─── Zone Points ────────────────────────────────────────────────────────────
--- Client sends updated zone_points for a patrol after drawing on the map.
--- points = array of { x, y } in GTA world coordinates, or nil to clear zone.
-RegisterNetEvent(resourceName .. ":server:setPatrolZone", function(id, points)
- local src = source
- if not CheckAuth(src) then return end
- if not isValidPatrolId(id) then return end
- if not patrols[id] then return end
- if not isValidZonePoints(points) then return end
-
- -- nil clears the zone; fewer than 3 points also clears it
- local hadZone = patrols[id].zonePoints ~= nil
- patrols[id].zonePoints = (points and #points >= 3) and points or nil
- broadcastPatrols()
- savePatrolNow(patrols[id]) -- zone data is precious → write immediately
- local zoneAction = patrols[id].zonePoints
- and (hadZone and 'patrol_zone_updated' or 'patrol_zone_created')
- or 'patrol_zone_cleared'
- local pts = patrols[id].zonePoints and #patrols[id].zonePoints or 0
- local label = patrols[id].zonePoints
- and ('Drew zone for patrol "%s" (%d points)'):format(patrols[id].name, pts)
- or ('Cleared zone for patrol "%s"'):format(patrols[id].name)
- dbg(label)
- auditPatrol(src, zoneAction, id, {
- patrol_name = patrols[id].name,
- point_count = pts,
- action_label = label,
- })
-end)
-
--- ─── Order / Assign ───────────────────────────────────────────────────────
-
-RegisterNetEvent(resourceName .. ":server:reorderPatrols", function(ids)
- local src = source
- if not CheckAuth(src) then return end
- if type(ids) ~= "table" then return end
-
- local seen = {}
- local newOrder = {}
- for _, id in ipairs(ids) do
- if isValidPatrolId(id) and patrols[id] and not seen[id] then
- seen[id] = true
- newOrder[#newOrder + 1] = id
- end
- end
- for _, id in ipairs(patrolOrder) do
- if not seen[id] then
- newOrder[#newOrder + 1] = id
- end
- end
-
- patrolOrder = newOrder
- saveOrder()
- broadcastPatrols()
- local nameOrder = {}
- for _, pid in ipairs(newOrder) do
- nameOrder[#nameOrder + 1] = patrols[pid] and patrols[pid].name or pid
- end
- dbg('patrols reordered: ' .. table.concat(nameOrder, ' -> '))
- -- NOTE: previous code passed `extra` as a 5th arg (after a nil), so it was
- -- silently dropped. auditPatrol is (src, action, patrolId, extra).
- auditPatrol(src, 'patrols_reordered', 'order', {
- new_order = table.concat(nameOrder, ' → '),
- action_label = 'Reordered patrols: ' .. table.concat(nameOrder, ' → '),
- })
-end)
-
-RegisterNetEvent(resourceName .. ":server:assignOfficer", function(patrolId, citizenId)
- local src = source
- if not CheckAuth(src) then return end
- if not isValidPatrolId(patrolId) or not isValidCitizenId(citizenId) then return end
- if not patrols[patrolId] then return end
-
- for _, patrol in pairs(patrols) do
- for i = #patrol.memberIds, 1, -1 do
- if patrol.memberIds[i] == citizenId then
- table.remove(patrol.memberIds, i)
- end
- end
- end
- table.insert(patrols[patrolId].memberIds, citizenId)
- broadcastPatrols("assigned", citizenId)
- savePatrol(patrols[patrolId]) -- frequent mutation → debounced
-
- local assignedName = getNameByCitizenId(citizenId)
- dbg(('assigned %s to "%s"'):format(assignedName, patrols[patrolId].name))
- auditPatrol(src, 'patrol_officer_assigned', patrolId, {
- patrol_name = patrols[patrolId].name,
- assigned_name = assignedName,
- assigned_id = citizenId,
- action_label = ('Assigned %s to patrol "%s"'):format(assignedName, patrols[patrolId].name),
- })
-end)
-
-RegisterNetEvent(resourceName .. ":server:removeFromPatrol", function(citizenId)
- local src = source
- if not CheckAuth(src) then return end
- if not isValidCitizenId(citizenId) then return end
-
- -- Find which patrol the officer belongs to BEFORE removal (for the audit log)
- local removedFromPatrol = 'unknown'
- for _, patrol in pairs(patrols) do
- for _, mid in ipairs(patrol.memberIds) do
- if mid == citizenId then removedFromPatrol = patrol.name; break end
- end
- end
- for _, patrol in pairs(patrols) do
- for i = #patrol.memberIds, 1, -1 do
- if patrol.memberIds[i] == citizenId then
- table.remove(patrol.memberIds, i)
- savePatrol(patrol) -- frequent mutation → debounced
- end
- end
- end
- broadcastPatrols("removed", citizenId)
- -- Only audit if the officer was actually found in a patrol
- if removedFromPatrol ~= 'unknown' then
- dbg(('removed %s from "%s"'):format(citizenId, removedFromPatrol))
- auditPatrol(src, 'patrol_officer_removed', citizenId, {
- removed_id = citizenId,
- removed_from = removedFromPatrol,
- action_label = ('Removed officer from patrol "%s"'):format(removedFromPatrol),
- })
- end
-end)
-
-AddEventHandler("playerDropped", function()
- local src = source
- cacheVehicleCooldowns[src] = nil
-
- local citizenId = nil
- local officerName = GetPlayerName(src) or ('Player #' .. src)
-
- local QBCore = getQBCore()
- if QBCore then
- local player = QBCore.Functions.GetPlayer(src)
- if player then
- citizenId = player.PlayerData.citizenid
- officerName = player.PlayerData.charinfo.firstname .. ' ' .. player.PlayerData.charinfo.lastname
- end
- elseif ps and ps.getIdentifier then
- citizenId = ps.getIdentifier(src)
- officerName = (ps.getPlayerName and ps.getPlayerName(src)) or officerName
- end
-
- if not citizenId then return end
-
- -- Find patrol membership BEFORE removal so we can log it
- local removedFromPatrol = nil
- local removedFromId = nil
- for pid, patrol in pairs(patrols) do
- for _, mid in ipairs(patrol.memberIds) do
- if mid == citizenId then
- removedFromPatrol = patrol.name
- removedFromId = pid
- break
- end
- end
- if removedFromPatrol then break end
- end
-
- -- Remove from patrol
- for _, patrol in pairs(patrols) do
- for i = #patrol.memberIds, 1, -1 do
- if patrol.memberIds[i] == citizenId then
- table.remove(patrol.memberIds, i)
- savePatrol(patrol) -- frequent mutation → debounced
- end
- end
- end
-
- broadcastPatrols()
-
- -- Audit log only if they were actually in a patrol
- if removedFromPatrol and ps.auditLog then
- dbg(('%s disconnected from patrol "%s"'):format(officerName, removedFromPatrol))
- ps.auditLog(src, 'patrol_officer_removed', removedFromId or citizenId, {
- officer_name = officerName,
- officer_id = citizenId,
- removed_from = removedFromPatrol,
- action_label = ('%s left patrol "%s" (disconnected)'):format(officerName, removedFromPatrol),
- })
- end
-end)
-
-AddEventHandler("onResourceStart", function(res)
- if res ~= resourceName then return end
-
- -- Wrapped in pcall so a missing/broken `mdt_patrols` table produces a clear
- -- error for the next dev instead of a hard crash on boot.
- local ok, rows = pcall(function()
- return MySQL.query.await("SELECT * FROM mdt_patrols ORDER BY sort_order ASC")
- end)
- if not ok or type(rows) ~= "table" then
- print(('^1[MDT]^7 Failed to load patrols. Is the `mdt_patrols` table installed? Error: %s')
- :format(tostring(rows)))
- return
- end
-
- patrolOrder = {}
- for _, row in ipairs(rows) do
- local zonePoints = nil
- if row.zone_points and row.zone_points ~= "" and row.zone_points ~= "null" then
- local okDecode, decoded = pcall(json.decode, row.zone_points)
- if okDecode and type(decoded) == "table" then
- zonePoints = decoded
- else
- print(('^3[MDT]^7 Patrol "%s" has unparseable zone_points; ignoring.'):format(row.id))
- end
- end
- patrols[row.id] = {
- id = row.id,
- name = row.name,
- color = row.color,
- memberIds = {},
- zonePoints = zonePoints,
- sortOrder = row.sort_order or 0,
- }
- patrolOrder[#patrolOrder + 1] = row.id
- end
- -- Members reset on restart (officers need to be reassigned after a restart)
- MySQL.execute("UPDATE mdt_patrols SET member_ids = '[]'", {})
- ps.debug(('^2[MDT]^7 Loaded %d patrol(s).')
- :format(#rows))
-end)
-
--- Flush any pending debounced saves before the resource stops so nothing queued
--- in the last SAVE_DEBOUNCE_MS window is lost on restart.
-AddEventHandler("onResourceStop", function(res)
- if res ~= resourceName then return end
- flushSaves()
-end)
\ No newline at end of file
diff --git a/server/backend/vehicles.lua b/server/backend/vehicles.lua
index 162c8b0d..5e27fd61 100644
--- a/server/backend/vehicles.lua
+++ b/server/backend/vehicles.lua
@@ -31,16 +31,6 @@ local function formatLabel(value)
return formatted
end
-local function parseStatus(raw)
- if not raw or raw == '' then return 'valid', '' end
- local ok, decoded = pcall(function() return json.decode(raw) end)
- if ok and type(decoded) == 'table' then
- return decoded.status or 'valid', decoded.reason or ''
- end
- -- Fallback für alte plain-text Werte
- return raw, ''
-end
-
local function getVehicleShared(model)
if not Core or not Core.Shared or not Core.Shared.Vehicles then
return nil
@@ -128,8 +118,7 @@ ps.registerCallback(resourceName .. ':server:GetVehicles', function(source)
local plate = v.plate and string.upper(v.plate) or 'UNKNOWN'
local reportCount = countSetItems(reportIdsByPlate[plate])
local hasActiveBolo = activeBoloByPlate[plate] == true or v.boloactive == 1
- local statusName, statusReason = parseStatus(v.status)
- local flags = buildVehicleFlags(v.stolen == 1, hasActiveBolo, statusName)
+ local flags = buildVehicleFlags(v.stolen == 1, hasActiveBolo, v.status)
table.insert(vehicles, {
id = v.id,
@@ -143,8 +132,7 @@ ps.registerCallback(resourceName .. ':server:GetVehicles', function(source)
image = (v.image and v.image ~= '' and v.image) or ('https://docs.fivem.net/vehicles/' .. v.vehicle .. '.webp'),
seenIn = reportCount,
points = tonumber(v.points) or 0,
- status = statusName,
- reason = statusReason,
+ status = v.status or 'valid',
core_state = tonumber(v.core_state) or 0,
})
end
@@ -205,21 +193,14 @@ ps.registerCallback(resourceName .. ':server:UpdateVehicle', function(source, pa
values[#values + 1] = payload.information
end
- if payload.image ~= nil then
- updates[#updates + 1] = 'mdt_vehicle_image = ?'
- values[#values + 1] = payload.image
- end
-
if points ~= nil then
updates[#updates + 1] = 'mdt_vehicle_points = ?'
values[#values + 1] = points
end
if status ~= nil then
- local reason = payload.reason or ''
- local encoded = json.encode({ status = status, reason = reason })
updates[#updates + 1] = 'mdt_vehicle_status = ?'
- values[#values + 1] = encoded
+ values[#values + 1] = status
end
if #updates == 0 then
@@ -297,8 +278,7 @@ ps.registerCallback(resourceName .. ':server:GetVehicle', function(source, plate
end
local reportCount = countSetItems(reportIdSet)
- local statusName, statusReason = parseStatus(row.status)
- local flags = buildVehicleFlags(row.stolen == 1, hasActiveBolo or row.boloactive == 1, statusName)
+ local flags = buildVehicleFlags(row.stolen == 1, hasActiveBolo or row.boloactive == 1, row.status)
return {
success = true,
@@ -314,8 +294,7 @@ ps.registerCallback(resourceName .. ':server:GetVehicle', function(source, plate
image = (row.image and row.image ~= '' and row.image) or ('https://docs.fivem.net/vehicles/' .. row.vehicle .. '.webp'),
information = row.information or '',
points = tonumber(row.points) or 0,
- status = statusName,
- reason = statusReason,
+ status = row.status or 'valid',
core_state = tonumber(row.core_state) or 0,
stolen = row.stolen == 1,
boloactive = row.boloactive == 1,
diff --git a/server/backend/weapons.lua b/server/backend/weapons.lua
index 64d5c76a..aa6d9e79 100644
--- a/server/backend/weapons.lua
+++ b/server/backend/weapons.lua
@@ -113,36 +113,19 @@ exports('registerWeapon', registerWeapon)
ps.registerCallback('ps-mdt:server:getWeapons', function(source)
if not CheckAuth(source) then return {} end
- local weapons = MySQL.query.await('SELECT * FROM mdt_weapons') or {}
+ local weapons = MySQL.query.await('SELECT * FROM mdt_weapons')
local newData = {}
local weaponBolo = {}
-
- -- Batch-resolve owner names in a single query to avoid an N+1 lookup per weapon
- local nameByOwner = {}
- do
- local owners, seen = {}, {}
- for _, v in pairs(weapons) do
- if v.owner and v.owner ~= '' and not seen[v.owner] then
- seen[v.owner] = true
- owners[#owners + 1] = v.owner
- end
- end
- if #owners > 0 then
- local placeholders = string.rep('?,', #owners - 1) .. '?'
- local profiles = MySQL.query.await('SELECT citizenid, fullname FROM mdt_profiles WHERE citizenid IN (' .. placeholders .. ')', owners) or {}
- for _, p in ipairs(profiles) do
- if p.fullname and p.fullname ~= '' then
- nameByOwner[p.citizenid] = p.fullname
- end
- end
- end
- end
-
for k, v in pairs(weapons) do
- -- Resolve owner name: batched mdt_profiles lookup first, then ps_lib fallback
+ -- Resolve owner name: try mdt_profiles first, then ps_lib lookup
local ownerName = 'Unknown'
if v.owner and v.owner ~= '' then
- ownerName = nameByOwner[v.owner] or ps.getPlayerNameByIdentifier(v.owner) or 'Unknown'
+ local profile = MySQL.single.await('SELECT fullname FROM mdt_profiles WHERE citizenid = ?', { v.owner })
+ if profile and profile.fullname and profile.fullname ~= '' then
+ ownerName = profile.fullname
+ else
+ ownerName = ps.getPlayerNameByIdentifier(v.owner) or 'Unknown'
+ end
end
-- Normalize weapon model to lowercase for class table lookup
@@ -158,7 +141,6 @@ ps.registerCallback('ps-mdt:server:getWeapons', function(source)
name = (QBCore and QBCore.Shared and QBCore.Shared.Weapons and QBCore.Shared.Weapons[GetHashKey(v.weaponModel)] and QBCore.Shared.Weapons[GetHashKey(v.weaponModel)].label) or v.weaponModel,
image = 'https://docs.fivem.net/weapons/' .. v.weaponModel:upper() .. '.png',
type = class[modelLower] and class[modelLower].type or 'unknown',
- flags = v.flags and json.decode(v.flags) or {},
}
table.insert(newData, weaponInfo)
end
@@ -178,56 +160,20 @@ ps.registerCallback('ps-mdt:server:getWeapons', function(source)
return { weapons = newData, bolos = weaponBolo }
end)
-ps.registerCallback(resourceName .. ':server:getWeaponOwnershipHistory', function(source, payload)
+ps.registerCallback(resourceName .. ':server:getWeaponOwnershipHistory', function(source, serial)
local src = source
- if not CheckAuth(src) then return {} end
-
- local serial = payload
-
+ if not CheckAuth(src) then return end
if not serial or serial == '' then return {} end
local rows = MySQL.query.await([[
- SELECT h.id, h.serial, h.owner, h.weapon_model, h.weapon_class, h.information, h.changed_by, h.reason, h.created_at,
- p.fullname AS owner_name,
- cb.fullname AS changed_by_name
- FROM mdt_weapon_ownership_history h
- LEFT JOIN mdt_profiles p ON p.citizenid = h.owner
- LEFT JOIN mdt_profiles cb ON cb.citizenid = h.changed_by
- WHERE h.serial = ?
- ORDER BY h.created_at DESC
+ SELECT id, serial, owner, weapon_model, weapon_class, information, changed_by, reason, created_at
+ FROM mdt_weapon_ownership_history
+ WHERE serial = ?
+ ORDER BY created_at DESC
]], { serial })
-
return rows or {}
end)
-ps.registerCallback(resourceName .. ':server:getWeaponConfig', function(source)
- return { weapons = Config.Weapons }
-end)
-
-ps.registerCallback(resourceName .. ':server:saveWeaponFlags', function(source, serial, flags)
- local src = source
- if not CheckAuth(src) then return { success = false } end
- if not serial or serial == '' then return { success = false } end
-
- local decoded
- if type(flags) == 'table' then
- decoded = flags
- elseif type(flags) == 'string' then
- decoded = json.decode(flags) or {}
- else
- decoded = {}
- end
-
- local encoded = json.encode(decoded)
- local affected = MySQL.update.await('UPDATE mdt_weapons SET flags = ? WHERE serial = ?', { encoded, serial })
-
- if affected and affected > 0 then
- return { success = true }
- else
- return { success = false, message = 'Database error' }
- end
-end)
-
-- Save/Edit Weapon Info (from NUI)
ps.registerCallback(resourceName .. ':server:saveWeaponInfo', function(source, payload)
local src = source
@@ -245,11 +191,6 @@ ps.registerCallback(resourceName .. ':server:saveWeaponInfo', function(source, p
return { success = false, message = 'Missing serial number' }
end
- -- Ensure profile exists before insert/update to avoid FK constraint error
- -- if owner and owner ~= '' then
- -- EnsureProfileExists(owner)
- -- end
-
local existing = MySQL.single.await('SELECT id FROM mdt_weapons WHERE serial = ? LIMIT 1', { serial })
if existing then
@@ -258,11 +199,6 @@ ps.registerCallback(resourceName .. ':server:saveWeaponInfo', function(source, p
SET information = ?, owner = ?, weaponClass = ?, weaponModel = ?
WHERE serial = ?
]], { notes, owner, weapClass, weapModel, serial })
-
- MySQL.insert.await([[
- INSERT INTO mdt_weapon_ownership_history (serial, owner, weapon_model, weapon_class, information, changed_by, reason)
- VALUES (?, ?, ?, ?, ?, ?, ?)
- ]], { serial, owner, weapModel, weapClass, notes, ps.getIdentifier(src), 'ownership_transfer' })
else
MySQL.insert.await([[
INSERT INTO mdt_weapons (serial, scratched, owner, information, weaponClass, weaponModel)
diff --git a/server/fivemanage.lua b/server/fivemanage.lua
index 80b71280..c1c3a012 100644
--- a/server/fivemanage.lua
+++ b/server/fivemanage.lua
@@ -140,15 +140,15 @@ ps.registerCallback(resourceName .. ':server:triggerSuspectMugshot', function(so
end)
-- Upload a profile photo for a suspect via base64 (from MDT UI)
-ps.registerCallback(resourceName .. ':server:uploadSuspectPhoto', function(source, citizenid, imageUrl)
- if not citizenid or not imageUrl then
+ps.registerCallback(resourceName .. ':server:uploadSuspectPhoto', function(source, citizenid, base64Image)
+ if not citizenid or not base64Image then
return { success = false, message = 'Missing data' }
end
- -- local imageUrl, uploadError = FiveManageUpload(base64Image, 'suspect_' .. citizenid .. '.png')
- -- if not imageUrl then
- -- return { success = false, message = 'Upload failed: ' .. (uploadError or 'Unknown error') }
- -- end
+ local imageUrl, uploadError = FiveManageUpload(base64Image, 'suspect_' .. citizenid .. '.png')
+ if not imageUrl then
+ return { success = false, message = 'Upload failed: ' .. (uploadError or 'Unknown error') }
+ end
-- Ensure profile exists
if not EnsureProfileExists(citizenid) then
diff --git a/sql/qbcore.sql b/sql/qbcore.sql
index 5d85f1cb..e7a47ba4 100644
--- a/sql/qbcore.sql
+++ b/sql/qbcore.sql
@@ -355,7 +355,7 @@ CREATE TABLE IF NOT EXISTS `mdt_reports_involved` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`reportid` int(10) unsigned NOT NULL,
`citizenid` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
- `type` varchar(15) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
+ `type` varchar(7) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
`notes` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `FK_mdt_reports_involved_mdt_reports` (`reportid`),
@@ -553,7 +553,7 @@ CREATE TABLE IF NOT EXISTS `mdt_audit_logs` (
ALTER TABLE `player_vehicles`
ADD COLUMN IF NOT EXISTS `mdt_vehicle_information` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL,
ADD COLUMN IF NOT EXISTS `mdt_vehicle_points` int(11) NOT NULL DEFAULT 0,
- ADD COLUMN IF NOT EXISTS `mdt_vehicle_status` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'valid',
+ ADD COLUMN IF NOT EXISTS `mdt_vehicle_status` enum('valid','suspended','expired','impounded') NOT NULL DEFAULT 'valid',
ADD COLUMN IF NOT EXISTS `mdt_vehicle_stolen` tinyint(1) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `mdt_vehicle_boloactive` tinyint(1) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `mdt_vehicle_image` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL;
@@ -566,7 +566,6 @@ CREATE TABLE IF NOT EXISTS `mdt_weapons` (
`information` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL,
`weaponClass` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL,
`weaponModel` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL,
- `flags` JSON DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `unique_serial` (`serial`),
KEY `FK_mdt_weapons_mdt_profiles` (`owner`),
@@ -1324,11 +1323,4 @@ CREATE TABLE IF NOT EXISTS `mdt_warrant_reviews` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- Add lawyer_requested column to mdt_reports
-ALTER TABLE `mdt_reports` ADD COLUMN IF NOT EXISTS `lawyer_requested` tinyint(1) NOT NULL DEFAULT 0;
--- Update mdt_reports_evidence with new columns
-ALTER TABLE mdt_reports_evidence
- ADD COLUMN IF NOT EXISTS title VARCHAR(255) NOT NULL DEFAULT '' AFTER reportid,
- ADD COLUMN IF NOT EXISTS images LONGTEXT NULL DEFAULT NULL AFTER stored;
-
--- Add flags column to mdt_weapons
-ALTER TABLE `mdt_weapons` ADD COLUMN IF NOT EXISTS `flags` JSON DEFAULT NULL;
+ALTER TABLE `mdt_reports` ADD COLUMN IF NOT EXISTS `lawyer_requested` tinyint(1) NOT NULL DEFAULT 0;
\ No newline at end of file
diff --git a/sql/qbx.sql b/sql/qbx.sql
index 445e6c8b..3cd9d15a 100644
--- a/sql/qbx.sql
+++ b/sql/qbx.sql
@@ -355,7 +355,7 @@ CREATE TABLE IF NOT EXISTS `mdt_reports_involved` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`reportid` int(10) unsigned NOT NULL,
`citizenid` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
- `type` varchar(15) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
+ `type` varchar(7) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
`notes` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `FK_mdt_reports_involved_mdt_reports` (`reportid`),
@@ -553,7 +553,7 @@ CREATE TABLE IF NOT EXISTS `mdt_audit_logs` (
ALTER TABLE `player_vehicles`
ADD COLUMN IF NOT EXISTS `mdt_vehicle_information` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
ADD COLUMN IF NOT EXISTS `mdt_vehicle_points` int(11) NOT NULL DEFAULT 0,
- ADD COLUMN IF NOT EXISTS `mdt_vehicle_status` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'valid',
+ ADD COLUMN IF NOT EXISTS `mdt_vehicle_status` enum('valid','suspended','expired','impounded') NOT NULL DEFAULT 'valid',
ADD COLUMN IF NOT EXISTS `mdt_vehicle_stolen` tinyint(1) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `mdt_vehicle_boloactive` tinyint(1) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `mdt_vehicle_image` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL;
@@ -566,7 +566,6 @@ CREATE TABLE IF NOT EXISTS `mdt_weapons` (
`information` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`weaponClass` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`weaponModel` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
- `flags` JSON DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `unique_serial` (`serial`),
KEY `FK_mdt_weapons_mdt_profiles` (`owner`),
@@ -1324,11 +1323,4 @@ CREATE TABLE IF NOT EXISTS `mdt_warrant_reviews` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Add lawyer_requested column to mdt_reports
-ALTER TABLE `mdt_reports` ADD COLUMN IF NOT EXISTS `lawyer_requested` tinyint(1) NOT NULL DEFAULT 0;
--- Update mdt_reports_evidence with new columns
-ALTER TABLE mdt_reports_evidence
- ADD COLUMN IF NOT EXISTS title VARCHAR(255) NOT NULL DEFAULT '' AFTER reportid,
- ADD COLUMN IF NOT EXISTS images LONGTEXT NULL DEFAULT NULL AFTER stored;
-
--- Add flags column to mdt_weapons
-ALTER TABLE `mdt_weapons` ADD COLUMN IF NOT EXISTS `flags` JSON DEFAULT NULL;
+ALTER TABLE `mdt_reports` ADD COLUMN IF NOT EXISTS `lawyer_requested` tinyint(1) NOT NULL DEFAULT 0;
\ No newline at end of file
diff --git a/web/src/components/ContentArea.svelte b/web/src/components/ContentArea.svelte
index 043e66f9..e92adcd2 100644
--- a/web/src/components/ContentArea.svelte
+++ b/web/src/components/ContentArea.svelte
@@ -35,7 +35,6 @@
import LegalDocuments from "../pages/doj/LegalDocuments.svelte";
import type { createInstanceStateService } from "../services/instanceStateService.svelte";
import type { createTabService } from "../services/tabService.svelte";
- import BulletInBoard from "@/pages/BulletInBoard.svelte";
interface Props {
authService: AuthService;
@@ -110,7 +109,7 @@
citizens: ["citizens_search"],
bolos: ["bolos_view", "bolos_create"],
vehicles: ["vehicles_search"],
- weapons: ["weapons_search", "weapons_add"],
+ weapons: ["weapons_search"],
cases: ["cases_view", "cases_create"],
evidence: ["evidence_view", "evidence_create"],
reports: ["reports_view", "reports_create"],
@@ -120,7 +119,6 @@
bodycams: ["bodycams_view"],
ia: ["ia_view"],
sop: ["sop_view", "sop_manage"],
- bulletin_board: ["bulletin_view"],
management: ["management_settings", "management_bulletins", "management_activity", "management_permissions", "management_tracking"],
settings: ["management_settings"],
};
@@ -149,8 +147,6 @@
cameras: "Cameras",
bodycams: "Bodycams",
management: "Settings",
- sop: "SOP",
- bulletin_board: "Bulletin Board",
settings: "Preferences",
};
return labels[pageId] || pageId;
@@ -208,11 +204,11 @@
{:else if activeComponent === "roster"}
{:else if activeComponent === "map"}
-
+
{:else if activeComponent === "vehicles"}
{:else if activeComponent === "weapons"}
-
+
{:else if activeComponent === "cases"}
{:else if String(activeComponent) === "evidence"}
@@ -229,8 +225,6 @@
{:else if activeComponent === "sop"}
- {:else if activeComponent === "bulletin_board"}
-
{:else if activeComponent === "management"}
{:else if activeComponent === "settings"}
diff --git a/web/src/components/InvolvedPersons.svelte b/web/src/components/InvolvedPersons.svelte
index 092fd94a..61d695d4 100644
--- a/web/src/components/InvolvedPersons.svelte
+++ b/web/src/components/InvolvedPersons.svelte
@@ -336,4 +336,4 @@
border: 1px dashed var(--border-color);
border-radius: 0.375rem;
}
-
\ No newline at end of file
+
diff --git a/web/src/components/management/ManagementActivity.svelte b/web/src/components/management/ManagementActivity.svelte
index d874d814..370a0608 100644
--- a/web/src/components/management/ManagementActivity.svelte
+++ b/web/src/components/management/ManagementActivity.svelte
@@ -23,202 +23,108 @@
}
const ACTION_LABELS: Record = {
- // Authentication
- mdt_login: "Logged into MDT",
+ mdt_login: "Logged into MDT",
mdt_logout: "Logged out of MDT",
- // Reports
report_created: "Created a report",
report_updated: "Updated a report",
report_deleted: "Deleted a report",
- // Warrants
warrant_issued: "Issued a warrant",
warrant_closed: "Closed a warrant",
- // Cases
- case_created: "Created a case",
- case_updated: "Updated a case",
- case_deleted: "Deleted a case",
- case_officer_assigned: "Assigned officer to case",
- case_officer_removed: "Removed officer from case",
- case_attachment_added: "Added case attachment",
- case_attachment_removed: "Removed case attachment",
- case_attachment_uploaded: "Uploaded case attachment",
- // Evidence
- evidence_added: "Added evidence",
- evidence_updated: "Updated evidence",
- evidence_deleted: "Deleted evidence",
- evidence_transferred: "Transferred evidence custody",
- evidence_image_added: "Added evidence image",
- evidence_image_removed: "Removed evidence image",
- evidence_linked_case: "Linked evidence to case",
- case_created_from_evidence:"Created case from evidence",
- // Weapons
+ case_created: "Created a case",
+ case_updated: "Updated a case",
+ case_deleted: "Deleted a case",
+ case_officer_assigned: "Assigned officer to case",
+ case_officer_removed: "Removed officer from case",
+ case_attachment_added: "Added case attachment",
+ case_attachment_removed: "Removed case attachment",
+ case_attachment_uploaded: "Uploaded case attachment",
+ evidence_added: "Added evidence",
+ evidence_updated: "Updated evidence",
+ evidence_deleted: "Deleted evidence",
+ evidence_transferred: "Transferred evidence custody",
+ evidence_image_added: "Added evidence image",
+ evidence_image_removed: "Removed evidence image",
+ evidence_linked_case: "Linked evidence to case",
+ case_created_from_evidence: "Created case from evidence",
weapon_created: "Registered a weapon",
weapon_updated: "Updated weapon record",
weapon_deleted: "Deleted weapon record",
- // Vehicles
- vehicle_updated: "Updated vehicle record",
- vehicle_impounded: "Impounded a vehicle",
- vehicle_released: "Released vehicle from impound",
- // Searches
+ vehicle_updated: "Updated vehicle record",
+ vehicle_impounded: "Impounded a vehicle",
+ vehicle_released: "Released vehicle from impound",
search_citizens: "Searched citizens",
- search_players: "Searched players",
+ search_players: "Searched players",
search_officers: "Searched officers",
- // Charges / Fines / Sentencing
- fine_processed: "Processed a fine",
- charge_updated: "Updated a charge",
- arrest_logged: "Logged an arrest",
- sent_to_jail: "Sent citizen to jail",
- // Officers / Dispatch
- callsign_changed: "Changed officer callsign",
- signal100_activated: "Activated Signal 100",
+ fine_processed: "Processed a fine",
+ charge_updated: "Updated a charge",
+ arrest_logged: "Logged an arrest",
+ sent_to_jail: "Sent citizen to jail",
+ callsign_changed: "Changed officer callsign",
+ signal100_activated: "Activated Signal 100",
signal100_deactivated: "Deactivated Signal 100",
- icu_deleted: "Deleted ICU record",
- // Cameras / Bodycams
- camera_viewed: "Viewed camera footage",
- bodycam_viewed: "Viewed bodycam footage",
- // ── Patrols ──────────────────────────────────────────────────────────
- patrol_created: "Created a patrol",
- patrol_deleted: "Deleted a patrol",
- patrol_renamed: "Renamed a patrol",
- patrol_zone_created: "Drew patrol zone",
- patrol_zone_updated: "Updated patrol zone",
- patrol_zone_cleared: "Cleared patrol zone",
- patrol_officer_assigned: "Assigned officer to patrol",
- patrol_officer_removed: "Removed officer from patrol",
- patrols_reordered: "Reordered patrols",
+ icu_deleted: "Deleted ICU record",
};
const ACTION_ICONS: Record = {
- mdt_login: { icon: "login", color: "#3b82f6" },
+ mdt_login: { icon: "login", color: "#3b82f6" },
mdt_logout: { icon: "logout", color: "#6b7280" },
report_created: { icon: "description", color: "#10b981" },
- report_updated: { icon: "edit_note", color: "#f59e0b" },
- report_deleted: { icon: "delete", color: "#ef4444" },
- warrant_issued: { icon: "gavel", color: "#ef4444" },
- warrant_closed: { icon: "check_circle",color: "#10b981" },
- case_created: { icon: "folder_open", color: "#3b82f6" },
- case_updated: { icon: "folder", color: "#f59e0b" },
- case_deleted: { icon: "folder_delete",color: "#ef4444" },
- case_officer_assigned: { icon: "person_add", color: "#10b981" },
- case_officer_removed: { icon: "person_remove", color: "#ef4444" },
- evidence_added: { icon: "inventory_2", color: "#8b5cf6" },
- evidence_updated: { icon: "inventory_2", color: "#f59e0b" },
- evidence_deleted: { icon: "delete", color: "#ef4444" },
- evidence_transferred: { icon: "swap_horiz", color: "#8b5cf6" },
- weapon_created: { icon: "security", color: "#10b981" },
- weapon_updated: { icon: "security", color: "#f59e0b" },
- weapon_deleted: { icon: "no_encryption", color: "#ef4444" },
- vehicle_updated: { icon: "directions_car", color: "#f59e0b" },
- vehicle_impounded: { icon: "local_parking", color: "#ef4444" },
- vehicle_released: { icon: "directions_car", color: "#10b981" },
- fine_processed: { icon: "payments", color: "#f59e0b" },
- charge_updated: { icon: "gavel", color: "#f59e0b" },
- arrest_logged: { icon: "front_hand", color: "#ef4444" },
- sent_to_jail: { icon: "lock", color: "#ef4444" },
- search_citizens: { icon: "person_search",color: "#6b7280" },
- search_players: { icon: "manage_search",color: "#6b7280" },
- search_officers: { icon: "badge", color: "#6b7280" },
- callsign_changed: { icon: "badge", color: "#3b82f6" },
- signal100_activated: { icon: "crisis_alert", color: "#ef4444" },
- signal100_deactivated: { icon: "crisis_alert", color: "#6b7280" },
- camera_viewed: { icon: "videocam", color: "#6b7280" },
- bodycam_viewed: { icon: "camera_alt", color: "#6b7280" },
- // Patrols
- patrol_created: { icon: "groups", color: "#38bdf8" },
- patrol_deleted: { icon: "group_remove", color: "#ef4444" },
- patrol_renamed: { icon: "edit", color: "#f59e0b" },
- patrol_zone_created: { icon: "draw", color: "#10b981" },
- patrol_zone_updated: { icon: "draw", color: "#f59e0b" },
- patrol_zone_cleared: { icon: "clear", color: "#ef4444" },
- patrol_officer_assigned: { icon: "person_add", color: "#10b981" },
- patrol_officer_removed: { icon: "person_remove", color: "#ef4444" },
- patrols_reordered: { icon: "swap_vert", color: "#6b7280" },
+ report_updated: { icon: "edit_note", color: "#f59e0b" },
+ report_deleted: { icon: "delete", color: "#ef4444" },
+ warrant_issued: { icon: "gavel", color: "#ef4444" },
+ warrant_closed: { icon: "check_circle", color: "#10b981" },
+ case_created: { icon: "folder_open", color: "#3b82f6" },
+ case_updated: { icon: "folder", color: "#f59e0b" },
+ case_deleted: { icon: "folder_delete", color: "#ef4444" },
+ evidence_added: { icon: "inventory_2", color: "#8b5cf6" },
+ evidence_deleted: { icon: "delete", color: "#ef4444" },
+ vehicle_updated: { icon: "directions_car", color: "#f59e0b" },
+ vehicle_impounded: { icon: "local_parking", color: "#ef4444" },
+ arrest_logged: { icon: "front_hand", color: "#ef4444" },
+ fine_processed: { icon: "payments", color: "#f59e0b" },
+ search_citizens: { icon: "person_search", color: "#6b7280" },
};
let activities: AuditLog[] = $state([]);
- let isLoading = $state(false);
- let searchQuery = $state("");
- let currentPage = $state(1);
- let totalItems = $state(0);
- let perPage = $state(25);
+ let isLoading = $state(false);
+ let searchQuery = $state("");
+ let currentPage = $state(1);
+ let totalItems = $state(0);
+ let perPage = $state(25);
let searchTimeout: ReturnType | null = null;
let totalPages = $derived(Math.max(1, Math.ceil(totalItems / perPage)));
function getActionLabel(action: string): string {
- return ACTION_LABELS[action] ?? action.replace(/_/g, " ");
+ return ACTION_LABELS[action] || action.replace(/_/g, " ");
}
function getActionIcon(action: string): { icon: string; color: string } {
- return ACTION_ICONS[action] ?? { icon: "info", color: "rgba(59, 130, 246, 0.6)" };
- }
-
- // Parse details JSON and extract the most useful human-readable string.
- function getDetailLine(log: AuditLog): string {
- if (!log.details) return "";
- let d: Record;
- try { d = JSON.parse(log.details); } catch { return ""; }
-
- // Prefer the pre-built readable label from the server
- if (typeof d.action_label === "string") return d.action_label;
-
- // Patrol-specific fallbacks
- if (log.action === "patrol_renamed") {
- if (d.patrol_old_name && d.patrol_new_name)
- return `"${d.patrol_old_name}" → "${d.patrol_new_name}"`;
- }
- if (log.action === "patrol_officer_assigned") {
- if (d.assigned_name && d.patrol_name)
- return `${d.assigned_name} → ${d.patrol_name}`;
- }
- if (log.action === "patrol_officer_removed") {
- if (d.removed_from) return `Removed from "${d.removed_from}"`;
- }
- if (log.action === "patrol_zone_created" || log.action === "patrol_zone_updated") {
- if (d.patrol_name && d.point_count) return `${d.patrol_name} · ${d.point_count} pts`;
- }
- if (log.action === "patrol_zone_cleared") {
- if (d.patrol_name) return `${d.patrol_name}`;
- }
- if (log.action === "patrols_reordered") {
- if (typeof d.new_order === "string") return d.new_order;
- }
- if (log.action === "patrol_created") {
- if (d.patrol_name) return String(d.patrol_name);
- }
- if (log.action === "patrol_deleted") {
- if (d.patrol_name) return String(d.patrol_name);
- }
- return "";
+ return ACTION_ICONS[action] || { icon: "info", color: "rgba(59, 130, 246, 0.6)" };
}
function getEntityLabel(log: AuditLog): string {
if (!log.entity_id) return "";
const typeLabels: Record = {
- profile: "Profile",
- report: "Report",
- warrant: "Warrant",
- case: "Case",
- evidence: "Evidence",
- vehicle: "Vehicle",
- weapon: "Weapon",
- citizen: "Citizen",
- search: "",
- dispatch: "Channel",
- fine: "Fine",
- charge: "Charge",
- arrest: "Arrest",
- icu: "ICU",
+ profile: "Profile",
+ report: "Report",
+ warrant: "Warrant",
+ case: "Case",
+ evidence: "Evidence",
+ vehicle: "Vehicle",
+ weapon: "Weapon",
+ citizen: "Citizen",
+ search: "",
+ dispatch: "Channel",
+ fine: "Fine",
+ charge: "Charge",
+ arrest: "Arrest",
+ icu: "ICU",
case_attachment: "Attachment",
- evidence_image: "Image",
- officer: "Officer",
- mdt_patrol: "Patrol",
+ evidence_image: "Image",
+ officer: "Officer",
};
- const label = typeLabels[log.entity_type] ?? log.entity_type;
- // For patrol actions entity_id is the UUID or 'order' – skip the #prefix for UUIDs
- if (log.entity_type === "mdt_patrol") {
- return label;
- }
+ const label = typeLabels[log.entity_type] || log.entity_type;
return label ? `${label} #${log.entity_id}` : `#${log.entity_id}`;
}
@@ -226,22 +132,23 @@
if (!value) return "Unknown";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
- const now = new Date();
+ const now = new Date();
const diff = now.getTime() - date.getTime();
const mins = Math.floor(diff / 60000);
- if (mins < 1) return "Just now";
+ if (mins < 1) return "Just now";
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
- if (days < 7) return `${days}d ago`;
+ if (days < 7) return `${days}d ago`;
return date.toLocaleDateString() + " " + date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
function formatFullTimestamp(value: string): string {
if (!value) return "";
const date = new Date(value);
- return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
+ if (Number.isNaN(date.getTime())) return value;
+ return date.toLocaleString();
}
async function loadActivity(page = 1) {
@@ -254,12 +161,12 @@
{ items: [], total: 0, page: 1, perPage: 25 },
);
if (response && response.items) {
- activities = response.items;
- totalItems = response.total || 0;
- currentPage = response.page || page;
+ activities = response.items;
+ totalItems = response.total || 0;
+ currentPage = response.page || page;
} else if (Array.isArray(response)) {
- activities = response as unknown as AuditLog[];
- totalItems = activities.length;
+ activities = response as unknown as AuditLog[];
+ totalItems = activities.length;
currentPage = 1;
} else {
activities = [];
@@ -274,21 +181,30 @@
}
}
+ function goToPage(page: number) {
+ if (page < 1 || page > totalPages || page === currentPage) return;
+ loadActivity(page);
+ }
+
+
function handleSearch(e: Event) {
const value = (e.target as HTMLInputElement).value;
searchQuery = value;
if (searchTimeout) clearTimeout(searchTimeout);
- searchTimeout = setTimeout(() => { currentPage = 1; loadActivity(1); }, 400);
+ searchTimeout = setTimeout(() => {
+ currentPage = 1;
+ loadActivity(1);
+ }, 400);
}
onMount(() => {
if (isEnvBrowser()) {
activities = [
- { id: 1, actor_name: "D2020 Sgt. J. Miller", action: "patrol_created", entity_type: "mdt_patrol", entity_id: "alpha-1", details: JSON.stringify({ patrol_name: "Alpha-1", patrol_color: "#38bdf8", action_label: 'Created patrol "Alpha-1"' }), created_at: new Date(Date.now() - 60000).toISOString() },
- { id: 2, actor_name: "D2020 Det. R. Santos", action: "patrol_officer_assigned", entity_type: "mdt_patrol", entity_id: "alpha-1", details: JSON.stringify({ patrol_name: "Alpha-1", assigned_name: "J. Miller", action_label: 'Assigned J. Miller to patrol "Alpha-1"' }), created_at: new Date(Date.now() - 300000).toISOString() },
- { id: 3, actor_name: "D2020 Ofc. K. Chen", action: "patrol_zone_created", entity_type: "mdt_patrol", entity_id: "alpha-1", details: JSON.stringify({ patrol_name: "Alpha-1", point_count: 6, action_label: 'Drew zone for patrol "Alpha-1" (6 points)' }), created_at: new Date(Date.now() - 3600000).toISOString() },
- { id: 4, actor_name: "D2020 Lt. A. Brooks", action: "patrol_renamed", entity_type: "mdt_patrol", entity_id: "alpha-1", details: JSON.stringify({ patrol_old_name: "Alpha", patrol_new_name: "Alpha-1", action_label: 'Renamed patrol "Alpha" → "Alpha-1"' }), created_at: new Date(Date.now() - 7200000).toISOString() },
- { id: 5, actor_name: "D2020 Ofc. M. Torres", action: "report_created", entity_type: "report", entity_id: "RPT-042", details: null, created_at: new Date(Date.now() - 86400000).toISOString() },
+ { id: 1, actor_name: "D2020 Sgt. J. Miller", action: "report_created", entity_type: "report", entity_id: "RPT-001", details: null, created_at: new Date(Date.now() - 300000).toISOString() },
+ { id: 2, actor_name: "D2020 Det. R. Santos", action: "warrant_issued", entity_type: "warrant", entity_id: "5", details: null, created_at: new Date(Date.now() - 3600000).toISOString() },
+ { id: 3, actor_name: "D2020 Ofc. K. Chen", action: "case_created", entity_type: "case", entity_id: "42", details: null, created_at: new Date(Date.now() - 7200000).toISOString() },
+ { id: 4, actor_name: "D2020 Lt. A. Brooks", action: "mdt_login", entity_type: "profile", entity_id: "BVX67053", details: null, created_at: new Date(Date.now() - 86400000).toISOString() },
+ { id: 5, actor_name: "D2020 Ofc. M. Torres", action: "evidence_added", entity_type: "evidence", entity_id: "12", details: null, created_at: new Date(Date.now() - 172800000).toISOString() },
];
totalItems = 5;
return;
@@ -317,22 +233,15 @@
{:else}
- Content *
-
- Basic HTML tags are supported: <b>, <ul>, <li>, <p>, etc.
-
-
- {#if canPin()}
-
-
-
-
- {/if}
-
-
-
-
-
-{/if}
-
-
-{#if deleteConfirm.open}
-
deleteConfirm = { open: false, postId: null }}>
-
e.stopPropagation()}>
-
- warning
-
Delete Post
-
-
-
-
Are you sure you want to delete this post? This action cannot be undone.
-
-
-
-
-{/if}
-
-
\ No newline at end of file
diff --git a/web/src/pages/Cases.svelte b/web/src/pages/Cases.svelte
index 2aa6d723..1c1c0bec 100644
--- a/web/src/pages/Cases.svelte
+++ b/web/src/pages/Cases.svelte
@@ -831,6 +831,16 @@