- aether-medical: Stationen/Belegung, Intensivstation, Blutbank, Fuhrpark, Nachrichten/Aushänge, Berichte und Textbausteine (server + NUI) - aether-admin: vollständiges Server-Admin-Panel als echtes FiveM-Resource, an aether-core gekoppelt (Rechte über aether_users.admin_group), mit WebRTC-Live-Kameras (Bildschirmfreigabe) und separatem Media-Server (SFU) - Prototypen admin/ vervollständigt - SQL-Schema aether_admin.sql - Docker-Compose mit MariaDB und idempotenter Auto-Migration (schema_migrations) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
384 lines
15 KiB
Lua
384 lines
15 KiB
Lua
-- =====================================================================
|
|
-- aether-police / Modul: Gewahrsamsverwaltung
|
|
--
|
|
-- Dieses Modul enthält BEWUSST keinen Automatismus:
|
|
-- * Niemand wird automatisch eingewiesen.
|
|
-- * Keine Frist entlässt jemanden.
|
|
-- * Abgelaufene Haftzeiten bleiben offen stehen, bis ein Beamter
|
|
-- entscheidet — sichtbar an `entlassen_von` / `entlassen_am`.
|
|
--
|
|
-- Das System führt die Frist mit und erinnert. Mehr nicht.
|
|
-- =====================================================================
|
|
|
|
Kern.RegistriereModul({
|
|
id = 'gewahrsam', name = 'Gewahrsam', icon = '🔒',
|
|
reihenfolge = 5, recht = 'gewahrsam.einweisen',
|
|
beschreibung = 'Zellenbelegung und Fristen',
|
|
})
|
|
|
|
local Core = exports['aether-core']
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- Hilfsfunktionen
|
|
-- ---------------------------------------------------------------------
|
|
|
|
--- Sucht eine Zelle in der Konfiguration
|
|
--- @param zellenId string|nil
|
|
--- @return table|nil
|
|
local function HoleZelle(zellenId)
|
|
for _, zelle in ipairs(Polizei.Config.Gewahrsam.zellen) do
|
|
if zelle.id == zellenId then return zelle end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
--- Lädt alle offenen Gewahrsamsfälle
|
|
--- @return table
|
|
local function LadeOffene()
|
|
local eintraege = MySQL.query.await([[
|
|
SELECT g.id, g.character, g.anzeige, g.haft_minuten, g.beginn, g.faellig_am,
|
|
g.grund, g.zelle, g.eingewiesen_von,
|
|
TIMESTAMPDIFF(SECOND, NOW(), g.faellig_am) AS rest_sekunden,
|
|
c.firstname, c.lastname, c.dob,
|
|
e.firstname AS e_vorname, e.lastname AS e_nachname,
|
|
p.dienstnummer AS e_dienstnummer
|
|
FROM police_gewahrsam g
|
|
JOIN aether_characters c ON c.id = g.character
|
|
LEFT JOIN aether_characters e ON e.id = g.eingewiesen_von
|
|
LEFT JOIN police_personal p ON p.`character` = g.eingewiesen_von
|
|
WHERE g.entlassen_am IS NULL
|
|
ORDER BY g.faellig_am ASC
|
|
]]) or {}
|
|
|
|
for _, eintrag in ipairs(eintraege) do
|
|
eintrag.rest_sekunden = tonumber(eintrag.rest_sekunden) or 0
|
|
eintrag.abgelaufen = eintrag.rest_sekunden <= 0
|
|
-- Ist die Person gerade online?
|
|
eintrag.online = Core:HoleQuelleNachCharakter(eintrag.character) ~= nil
|
|
end
|
|
|
|
return eintraege
|
|
end
|
|
|
|
--- Verbringt eine Person in eine Zelle (nur wenn sie online ist)
|
|
--- @param charakterId number
|
|
--- @param zelle table Zellendefinition
|
|
local function VerbringeInZelle(charakterId, zelle)
|
|
if not Polizei.Config.Gewahrsam.verbringen or not zelle then return end
|
|
|
|
local zielQuelle = Core:HoleQuelleNachCharakter(charakterId)
|
|
if not zielQuelle then return end
|
|
|
|
TriggerClientEvent('aether:police:gewahrsamVerbringen', zielQuelle, {
|
|
x = zelle.pos.x, y = zelle.pos.y, z = zelle.pos.z, w = zelle.pos.w,
|
|
zelle = zelle.name,
|
|
})
|
|
end
|
|
|
|
-- =====================================================================
|
|
-- ÜBERSICHT
|
|
-- =====================================================================
|
|
|
|
Kern.RegistriereAktion('gewahrsam', 'liste', nil, function(quelle, beamter)
|
|
local offene = LadeOffene()
|
|
|
|
-- Zellenbelegung ermitteln
|
|
local belegt = {}
|
|
for _, eintrag in ipairs(offene) do
|
|
if eintrag.zelle then belegt[eintrag.zelle] = eintrag end
|
|
end
|
|
|
|
local zellen = {}
|
|
for _, zelle in ipairs(Polizei.Config.Gewahrsam.zellen) do
|
|
local insasse = belegt[zelle.id]
|
|
zellen[#zellen + 1] = {
|
|
id = zelle.id, name = zelle.name,
|
|
frei = insasse == nil,
|
|
insasse = insasse and {
|
|
id = insasse.id,
|
|
name = insasse.firstname .. ' ' .. insasse.lastname,
|
|
rest = insasse.rest_sekunden,
|
|
abgelaufen = insasse.abgelaufen,
|
|
} or nil,
|
|
}
|
|
end
|
|
|
|
-- Kürzlich Entlassene (nur zur Nachvollziehbarkeit)
|
|
local verlauf = MySQL.query.await([[
|
|
SELECT g.id, g.haft_minuten, g.beginn, g.entlassen_am, g.entlass_grund, g.grund,
|
|
c.firstname, c.lastname,
|
|
e.firstname AS e_vorname, e.lastname AS e_nachname
|
|
FROM police_gewahrsam g
|
|
JOIN aether_characters c ON c.id = g.character
|
|
LEFT JOIN aether_characters e ON e.id = g.entlassen_von
|
|
WHERE g.entlassen_am IS NOT NULL
|
|
ORDER BY g.entlassen_am DESC LIMIT 20
|
|
]]) or {}
|
|
|
|
return {
|
|
offene = offene,
|
|
zellen = zellen,
|
|
verlauf = verlauf,
|
|
maxDauer = Polizei.Config.Gewahrsam.maxDauer,
|
|
verbringen = Polizei.Config.Gewahrsam.verbringen,
|
|
rechte = {
|
|
einweisen = Rechte.Hat(beamter, 'gewahrsam.einweisen'),
|
|
entlassen = Rechte.Hat(beamter, 'gewahrsam.entlassen'),
|
|
},
|
|
}
|
|
end)
|
|
|
|
-- =====================================================================
|
|
-- EINWEISEN
|
|
--
|
|
-- Geschieht nur auf ausdrückliche Bestätigung eines Beamten. Es gibt
|
|
-- keinen Weg, hier ohne dessen Zutun hineinzukommen.
|
|
-- =====================================================================
|
|
|
|
Kern.RegistriereAktion('gewahrsam', 'einweisen', 'gewahrsam.einweisen', function(quelle, beamter, daten)
|
|
local charakterId = tonumber(daten.charakterId)
|
|
if not charakterId then return nil, 'Keine Person ausgewählt.' end
|
|
|
|
local person = MySQL.query.await(
|
|
'SELECT firstname, lastname FROM aether_characters WHERE id = ? AND deleted = 0',
|
|
{ charakterId })
|
|
if not person or not person[1] then return nil, 'Diese Person existiert nicht.' end
|
|
|
|
-- Doppelte Einweisung verhindern
|
|
local bereits = MySQL.scalar.await(
|
|
'SELECT id FROM police_gewahrsam WHERE `character` = ? AND entlassen_am IS NULL',
|
|
{ charakterId })
|
|
if bereits then return nil, 'Diese Person befindet sich bereits in Gewahrsam.' end
|
|
|
|
-- Haftdauer: kommt ausschliesslich vom Beamten
|
|
local dauer = math.floor(tonumber(daten.dauer) or 0)
|
|
if dauer < 1 or dauer > Polizei.Config.Gewahrsam.maxDauer then
|
|
return nil, ('Die Haftdauer muss zwischen 1 und %d Minuten liegen.')
|
|
:format(Polizei.Config.Gewahrsam.maxDauer)
|
|
end
|
|
|
|
local grund = type(daten.grund) == 'string'
|
|
and daten.grund:gsub('^%s+', ''):gsub('%s+$', '') or ''
|
|
if #grund < 3 then return nil, 'Bitte einen Grund angeben.' end
|
|
|
|
-- Zelle prüfen und Belegung sicherstellen
|
|
local zelle = HoleZelle(daten.zelle)
|
|
if daten.zelle and not zelle then return nil, 'Unbekannte Zelle.' end
|
|
|
|
if zelle then
|
|
local besetzt = MySQL.scalar.await(
|
|
'SELECT id FROM police_gewahrsam WHERE zelle = ? AND entlassen_am IS NULL',
|
|
{ zelle.id })
|
|
if besetzt then return nil, ('%s ist bereits belegt.'):format(zelle.name) end
|
|
end
|
|
|
|
local gewahrsamId = MySQL.insert.await([[
|
|
INSERT INTO police_gewahrsam
|
|
(`character`, anzeige, haft_minuten, faellig_am, grund, zelle, eingewiesen_von)
|
|
VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL ? MINUTE), ?, ?, ?)
|
|
]], {
|
|
charakterId, tonumber(daten.anzeige), dauer, dauer, grund,
|
|
zelle and zelle.id or nil, beamter.charakterId,
|
|
})
|
|
|
|
-- Person in die Zelle bringen, sofern konfiguriert und online
|
|
VerbringeInZelle(charakterId, zelle)
|
|
|
|
Protokoll.Schreibe(beamter, 'gewahrsam', 'In Gewahrsam genommen',
|
|
{ typ = 'person', id = charakterId },
|
|
{ gewahrsam = gewahrsamId, dauer = dauer, zelle = zelle and zelle.id }, grund)
|
|
|
|
Hinweise.Erstelle(nil, {
|
|
kategorie = 'gewahrsam', prioritaet = 'normal',
|
|
titel = ('%s %s in Gewahrsam'):format(person[1].firstname, person[1].lastname),
|
|
text = ('%d Minuten · %s · eingewiesen von %s')
|
|
:format(dauer, grund, beamter.dienstnummer),
|
|
zielTyp = 'gewahrsam', zielId = gewahrsamId,
|
|
})
|
|
|
|
Kern.Loese('gewahrsam.eingewiesen', gewahrsamId, beamter)
|
|
|
|
return { id = gewahrsamId }
|
|
end)
|
|
|
|
-- =====================================================================
|
|
-- ENTLASSEN
|
|
--
|
|
-- Der einzige Weg aus dem Gewahrsam. Es gibt keinen Hintergrundprozess,
|
|
-- der das übernimmt — auch nicht bei längst abgelaufener Frist.
|
|
-- =====================================================================
|
|
|
|
Kern.RegistriereAktion('gewahrsam', 'entlassen', 'gewahrsam.entlassen', function(quelle, beamter, daten)
|
|
local gewahrsamId = tonumber(daten.id)
|
|
if not gewahrsamId then return nil, 'Ungültige Kennung.' end
|
|
|
|
local eintrag = MySQL.query.await([[
|
|
SELECT g.*, c.firstname, c.lastname,
|
|
TIMESTAMPDIFF(MINUTE, NOW(), g.faellig_am) AS rest
|
|
FROM police_gewahrsam g
|
|
JOIN aether_characters c ON c.id = g.character
|
|
WHERE g.id = ?
|
|
]], { gewahrsamId })
|
|
|
|
if not eintrag or not eintrag[1] then return nil, 'Vorgang nicht gefunden.' end
|
|
local g = eintrag[1]
|
|
|
|
if g.entlassen_am then return nil, 'Diese Person wurde bereits entlassen.' end
|
|
|
|
local grund = type(daten.grund) == 'string'
|
|
and daten.grund:gsub('^%s+', ''):gsub('%s+$', '') or ''
|
|
|
|
-- Vorzeitige Entlassung verlangt eine Begründung
|
|
local rest = tonumber(g.rest) or 0
|
|
local vorzeitig = rest > 0
|
|
if vorzeitig and #grund < 3 then
|
|
return nil, ('Die Frist läuft noch %d Minuten. Für eine vorzeitige Entlassung ist eine Begründung erforderlich.')
|
|
:format(rest)
|
|
end
|
|
|
|
MySQL.update.await([[
|
|
UPDATE police_gewahrsam
|
|
SET entlassen_von = ?, entlassen_am = NOW(), entlass_grund = ?
|
|
WHERE id = ?
|
|
]], { beamter.charakterId, grund ~= '' and grund or nil, gewahrsamId })
|
|
|
|
-- Person an den Entlassungspunkt setzen, sofern online
|
|
if Polizei.Config.Gewahrsam.verbringen then
|
|
local zielQuelle = Core:HoleQuelleNachCharakter(g.character)
|
|
if zielQuelle then
|
|
local punkt = Polizei.Config.Gewahrsam.entlassung
|
|
TriggerClientEvent('aether:police:gewahrsamEntlassen', zielQuelle, {
|
|
x = punkt.x, y = punkt.y, z = punkt.z, w = punkt.w,
|
|
})
|
|
end
|
|
end
|
|
|
|
Protokoll.Schreibe(beamter, 'gewahrsam',
|
|
vorzeitig and 'Vorzeitig entlassen' or 'Entlassen',
|
|
{ typ = 'person', id = g.character },
|
|
{ gewahrsam = gewahrsamId, restMinuten = rest }, grund ~= '' and grund or nil)
|
|
|
|
Hinweise.Erstelle(nil, {
|
|
kategorie = 'gewahrsam', prioritaet = 'niedrig',
|
|
titel = ('%s %s entlassen'):format(g.firstname, g.lastname),
|
|
text = ('Entlassen durch %s%s'):format(beamter.dienstnummer,
|
|
vorzeitig and (' · vorzeitig: ' .. grund) or ''),
|
|
})
|
|
|
|
Kern.Loese('gewahrsam.entlassen', gewahrsamId, beamter)
|
|
|
|
return { ok = true, vorzeitig = vorzeitig }
|
|
end)
|
|
|
|
-- =====================================================================
|
|
-- HAFTZEIT ANPASSEN
|
|
--
|
|
-- Auch das ist eine bewusste Entscheidung und wird protokolliert.
|
|
-- =====================================================================
|
|
|
|
Kern.RegistriereAktion('gewahrsam', 'fristAendern', 'gewahrsam.entlassen', function(quelle, beamter, daten)
|
|
local gewahrsamId = tonumber(daten.id)
|
|
local minuten = math.floor(tonumber(daten.minuten) or 0)
|
|
|
|
if not gewahrsamId then return nil, 'Ungültige Kennung.' end
|
|
if minuten == 0 then return nil, 'Bitte eine Änderung angeben.' end
|
|
if math.abs(minuten) > Polizei.Config.Gewahrsam.maxDauer then
|
|
return nil, 'Die Änderung ist zu groß.'
|
|
end
|
|
|
|
local grund = type(daten.grund) == 'string' and daten.grund:sub(1, 255) or ''
|
|
if #grund < 3 then return nil, 'Bitte einen Grund für die Änderung angeben.' end
|
|
|
|
local eintrag = MySQL.query.await(
|
|
'SELECT `character`, entlassen_am FROM police_gewahrsam WHERE id = ?', { gewahrsamId })
|
|
if not eintrag or not eintrag[1] then return nil, 'Vorgang nicht gefunden.' end
|
|
if eintrag[1].entlassen_am then return nil, 'Der Vorgang ist bereits abgeschlossen.' end
|
|
|
|
MySQL.update.await([[
|
|
UPDATE police_gewahrsam
|
|
SET faellig_am = DATE_ADD(faellig_am, INTERVAL ? MINUTE),
|
|
haft_minuten = GREATEST(0, haft_minuten + ?)
|
|
WHERE id = ?
|
|
]], { minuten, minuten, gewahrsamId })
|
|
|
|
Protokoll.Schreibe(beamter, 'gewahrsam', 'Haftzeit angepasst',
|
|
{ typ = 'person', id = eintrag[1].character },
|
|
{ gewahrsam = gewahrsamId, aenderung = minuten }, grund)
|
|
|
|
return { ok = true }
|
|
end)
|
|
|
|
-- =====================================================================
|
|
-- ZELLE WECHSELN
|
|
-- =====================================================================
|
|
|
|
Kern.RegistriereAktion('gewahrsam', 'zelleWechseln', 'gewahrsam.einweisen', function(quelle, beamter, daten)
|
|
local gewahrsamId = tonumber(daten.id)
|
|
local zelle = HoleZelle(daten.zelle)
|
|
|
|
if not gewahrsamId or not zelle then return nil, 'Unvollständige Angaben.' end
|
|
|
|
local besetzt = MySQL.scalar.await(
|
|
'SELECT id FROM police_gewahrsam WHERE zelle = ? AND entlassen_am IS NULL AND id <> ?',
|
|
{ zelle.id, gewahrsamId })
|
|
if besetzt then return nil, ('%s ist bereits belegt.'):format(zelle.name) end
|
|
|
|
local eintrag = MySQL.query.await(
|
|
'SELECT `character` FROM police_gewahrsam WHERE id = ? AND entlassen_am IS NULL',
|
|
{ gewahrsamId })
|
|
if not eintrag or not eintrag[1] then return nil, 'Vorgang nicht gefunden.' end
|
|
|
|
MySQL.update.await('UPDATE police_gewahrsam SET zelle = ? WHERE id = ?', { zelle.id, gewahrsamId })
|
|
VerbringeInZelle(eintrag[1].character, zelle)
|
|
|
|
Protokoll.Schreibe(beamter, 'gewahrsam', 'Zelle gewechselt',
|
|
{ typ = 'person', id = eintrag[1].character }, { zelle = zelle.id })
|
|
|
|
return { ok = true }
|
|
end)
|
|
|
|
-- =====================================================================
|
|
-- BEI ANMELDUNG PRÜFEN
|
|
--
|
|
-- Wer während einer offenen Haft ausloggt, findet sich beim nächsten
|
|
-- Anmelden wieder in der Zelle. Die Frist läuft derweil weiter, aber
|
|
-- entlassen wird trotzdem nur durch einen Beamten.
|
|
-- =====================================================================
|
|
AddEventHandler('aether:server:spielerGeladen', function(quelle, spieler)
|
|
if not Polizei.Config.Gewahrsam.verbringen then return end
|
|
|
|
local eintrag = MySQL.query.await([[
|
|
SELECT id, zelle, grund, TIMESTAMPDIFF(MINUTE, NOW(), faellig_am) AS rest
|
|
FROM police_gewahrsam
|
|
WHERE `character` = ? AND entlassen_am IS NULL
|
|
LIMIT 1
|
|
]], { spieler.charakterId })
|
|
|
|
if not eintrag or not eintrag[1] then return end
|
|
|
|
local zelle = HoleZelle(eintrag[1].zelle)
|
|
if not zelle then return end
|
|
|
|
-- Kurz warten, bis der Spieler wirklich in der Welt ist
|
|
Wait(3000)
|
|
|
|
TriggerClientEvent('aether:police:gewahrsamVerbringen', quelle, {
|
|
x = zelle.pos.x, y = zelle.pos.y, z = zelle.pos.z, w = zelle.pos.w,
|
|
zelle = zelle.name,
|
|
rest = tonumber(eintrag[1].rest) or 0,
|
|
grund = eintrag[1].grund,
|
|
})
|
|
end)
|
|
|
|
-- =====================================================================
|
|
-- EXPORT — Auskunft für andere Module
|
|
-- =====================================================================
|
|
|
|
--- Befindet sich eine Person in Gewahrsam?
|
|
exports('IstInGewahrsam', function(charakterId)
|
|
local treffer = MySQL.scalar.await(
|
|
'SELECT id FROM police_gewahrsam WHERE `character` = ? AND entlassen_am IS NULL',
|
|
{ charakterId })
|
|
return treffer ~= nil
|
|
end)
|