aether-framework/resources/[aether]/aether-police/server/module/gewahrsam.lua
Jerrit Fritzsche e4b82dd685 Aether-Framework: Fachmodule, Admin-Panel und Docker-Datenbank
- 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>
2026-07-27 18:55:32 +02:00

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)