aether-framework/resources/[aether]/aether-police/server/module/fahndung.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

400 lines
16 KiB
Lua

-- =====================================================================
-- aether-police / Modul: Fahndungen
--
-- Fahndungen entstehen ausschliesslich durch einen Beamten. Es gibt
-- keinen Automatismus, der jemanden aufgrund von Vorgängen zur Fahndung
-- ausschreibt — und keinen, der eine Fahndung von selbst aufhebt.
-- =====================================================================
Kern.RegistriereModul({
id = 'fahndung', name = 'Fahndungen', icon = '🔍',
reihenfolge = 9, recht = 'fahndung.erstellen',
beschreibung = 'Personen- und Sachfahndung',
})
-- ---------------------------------------------------------------------
-- Hilfsfunktionen
-- ---------------------------------------------------------------------
local GEFAHRSTUFEN = { normal = true, bewaffnet = true, gefaehrlich = true }
--- Ermittelt die Bezeichnung eines Fahndungsziels
--- @param typ string 'person' | 'fahrzeug'
--- @param zielId string
--- @return string|nil Bezeichnung, string|nil Fehler
local function BezeichnungFuerZiel(typ, zielId)
if typ == 'person' then
local charakterId = tonumber(zielId)
if not charakterId then return nil, 'Ungültige Personenkennung.' end
local person = MySQL.query.await(
'SELECT firstname, lastname, dob FROM aether_characters WHERE id = ? AND deleted = 0',
{ charakterId })
if not person or not person[1] then return nil, 'Diese Person existiert nicht.' end
return ('%s %s (geb. %s)'):format(
person[1].firstname, person[1].lastname,
tostring(person[1].dob):sub(1, 10))
end
-- Fahrzeug
local kennzeichen = tostring(zielId):upper()
local fahrzeug = MySQL.query.await([[
SELECT v.plate, v.model, v.props, c.firstname, c.lastname
FROM aether_vehicles v
LEFT JOIN aether_characters c ON c.id = v.owner
WHERE UPPER(v.plate) = ?
]], { kennzeichen })
if not fahrzeug or not fahrzeug[1] then
return nil, 'Kein Fahrzeug mit diesem Kennzeichen.'
end
local name = fahrzeug[1].model
if fahrzeug[1].props then
local erfolg, props = pcall(json.decode, fahrzeug[1].props)
if erfolg and type(props) == 'table' and props.name then name = props.name end
end
return ('%s · %s'):format(fahrzeug[1].plate, name)
end
--- Reichert eine Fahndung um die Daten ihres Ziels an
--- @param fahndung table
local function ZielDatenErgaenzen(fahndung)
if fahndung.typ == 'person' then
local person = MySQL.query.await([[
SELECT c.id, c.firstname, c.lastname, c.dob, p.lichtbild,
p.warnhinweis, p.gefahrenstufe
FROM aether_characters c
LEFT JOIN police_person p ON p.`character` = c.id
WHERE c.id = ?
]], { tonumber(fahndung.ziel_id) })
fahndung.ziel = person and person[1] or nil
else
local fahrzeug = MySQL.query.await([[
SELECT v.plate, v.model, v.props, v.state, v.owner,
c.firstname, c.lastname
FROM aether_vehicles v
LEFT JOIN aether_characters c ON c.id = v.owner
WHERE v.plate = ?
]], { fahndung.ziel_id })
if fahrzeug and fahrzeug[1] then
local eintrag = fahrzeug[1]
if eintrag.props then
local erfolg, props = pcall(json.decode, eintrag.props)
if erfolg and type(props) == 'table' then
eintrag.name = props.name
eintrag.farbe = props.farbe
end
end
eintrag.props = nil
fahndung.ziel = eintrag
end
end
end
-- =====================================================================
-- ÜBERSICHT
-- =====================================================================
Kern.RegistriereAktion('fahndung', 'liste', nil, function(quelle, beamter, daten)
local bedingungen, werte = { '1=1' }, {}
-- Standardmäßig nur aktive Fahndungen
if daten.archiv then
bedingungen[#bedingungen + 1] = 'f.aktiv = 0'
else
bedingungen[#bedingungen + 1] = 'f.aktiv = 1'
end
if daten.typ and daten.typ ~= '' then
bedingungen[#bedingungen + 1] = 'f.typ = ?'
werte[#werte + 1] = daten.typ
end
if daten.gefahr and daten.gefahr ~= '' then
bedingungen[#bedingungen + 1] = 'f.gefahr = ?'
werte[#werte + 1] = daten.gefahr
end
if type(daten.begriff) == 'string' and #daten.begriff >= 2 then
bedingungen[#bedingungen + 1] = '(f.bezeichnung LIKE ? OR f.grund LIKE ?)'
local muster = '%' .. daten.begriff .. '%'
werte[#werte + 1] = muster
werte[#werte + 1] = muster
end
local fahndungen = MySQL.query.await(([[
SELECT f.id, f.typ, f.ziel_id, f.bezeichnung, f.grund, f.gefahr,
f.prioritaet, f.hinweise, f.bild, f.aktiv, f.erstellt_am,
f.aufgehoben_am,
c.firstname, c.lastname, p.dienstnummer,
a.firstname AS a_vorname, a.lastname AS a_nachname
FROM police_fahndung f
LEFT JOIN aether_characters c ON c.id = f.erstellt_von
LEFT JOIN police_personal p ON p.`character` = f.erstellt_von
LEFT JOIN aether_characters a ON a.id = f.aufgehoben_von
WHERE %s
ORDER BY f.prioritaet ASC, f.id DESC
LIMIT 80
]]):format(table.concat(bedingungen, ' AND ')), werte) or {}
-- Zahlen für die Übersicht
local zahlen = MySQL.query.await([[
SELECT gefahr, COUNT(*) AS anzahl FROM police_fahndung WHERE aktiv = 1 GROUP BY gefahr
]]) or {}
local bestand = { normal = 0, bewaffnet = 0, gefaehrlich = 0 }
for _, zeile in ipairs(zahlen) do bestand[zeile.gefahr] = zeile.anzahl end
return {
fahndungen = fahndungen,
bestand = bestand,
rechte = {
erstellen = Rechte.Hat(beamter, 'fahndung.erstellen'),
aufheben = Rechte.Hat(beamter, 'fahndung.aufheben'),
},
}
end)
Kern.RegistriereAktion('fahndung', 'oeffnen', nil, function(quelle, beamter, daten)
local fahndungId = tonumber(daten.id)
if not fahndungId then return nil, 'Ungültige Kennung.' end
local zeilen = MySQL.query.await([[
SELECT f.*, c.firstname, c.lastname, p.dienstnummer,
a.firstname AS a_vorname, a.lastname AS a_nachname
FROM police_fahndung f
LEFT JOIN aether_characters c ON c.id = f.erstellt_von
LEFT JOIN police_personal p ON p.`character` = f.erstellt_von
LEFT JOIN aether_characters a ON a.id = f.aufgehoben_von
WHERE f.id = ?
]], { fahndungId })
if not zeilen or not zeilen[1] then return nil, 'Fahndung nicht gefunden.' end
local fahndung = zeilen[1]
ZielDatenErgaenzen(fahndung)
return {
fahndung = fahndung,
historie = Protokoll.Lies({ zielTyp = 'fahndung', zielId = fahndungId, limit = 20 }),
rechte = {
bearbeiten = Rechte.Hat(beamter, 'fahndung.erstellen'),
aufheben = Rechte.Hat(beamter, 'fahndung.aufheben'),
},
}
end)
-- =====================================================================
-- AUSSCHREIBEN
-- =====================================================================
Kern.RegistriereAktion('fahndung', 'erstellen', 'fahndung.erstellen', function(quelle, beamter, daten)
local typ = (daten.typ == 'fahrzeug') and 'fahrzeug' or 'person'
local zielId = tostring(daten.zielId or ''):gsub('^%s+', ''):gsub('%s+$', '')
if zielId == '' then return nil, 'Kein Ziel angegeben.' end
if typ == 'fahrzeug' then zielId = zielId:upper() end
-- Ziel muss existieren
local bezeichnung, fehler = BezeichnungFuerZiel(typ, zielId)
if not bezeichnung then return nil, fehler end
-- Doppelte aktive Fahndung verhindern
local vorhanden = MySQL.scalar.await(
'SELECT id FROM police_fahndung WHERE typ = ? AND ziel_id = ? AND aktiv = 1',
{ typ, zielId })
if vorhanden then
return nil, 'Für dieses Ziel besteht bereits eine aktive Fahndung.'
end
local grund = type(daten.grund) == 'string'
and daten.grund:gsub('^%s+', ''):gsub('%s+$', '') or ''
if #grund < 5 then
return nil, 'Bitte einen aussagekräftigen Fahndungsgrund angeben.'
end
local gefahr = GEFAHRSTUFEN[daten.gefahr] and daten.gefahr or 'normal'
local prioritaet = math.max(1, math.min(4, tonumber(daten.prioritaet) or 3))
local fahndungId = MySQL.insert.await([[
INSERT INTO police_fahndung
(typ, ziel_id, bezeichnung, grund, gefahr, prioritaet, hinweise, bild, erstellt_von)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
]], {
typ, zielId, bezeichnung, grund:sub(1, 512), gefahr, prioritaet,
type(daten.hinweise) == 'string' and daten.hinweise or nil,
type(daten.bild) == 'string' and daten.bild:sub(1, 2000) or nil,
beamter.charakterId,
})
Protokoll.Schreibe(beamter, 'fahndung', 'Fahndung ausgeschrieben',
{ typ = 'fahndung', id = fahndungId },
{ ziel = typ .. ':' .. zielId, gefahr = gefahr, prioritaet = prioritaet }, grund)
-- Alle Beamten im Dienst informieren; Gefahrenstufe bestimmt die Dringlichkeit
Hinweise.Erstelle(nil, {
kategorie = 'fahndung',
prioritaet = gefahr == 'gefaehrlich' and 'kritisch'
or gefahr == 'bewaffnet' and 'hoch' or 'normal',
titel = ('Fahndung: %s'):format(bezeichnung),
text = ('%s · Gefahrenstufe: %s · ausgeschrieben von %s')
:format(grund, gefahr, beamter.dienstnummer),
zielTyp = 'fahndung', zielId = fahndungId,
})
Kern.Loese('fahndung.erstellt', fahndungId, beamter)
return { id = fahndungId, bezeichnung = bezeichnung }
end)
-- =====================================================================
-- BEARBEITEN
-- =====================================================================
Kern.RegistriereAktion('fahndung', 'bearbeiten', 'fahndung.erstellen', function(quelle, beamter, daten)
local fahndungId = tonumber(daten.id)
if not fahndungId then return nil, 'Ungültige Kennung.' end
local fahndung = MySQL.query.await(
'SELECT gefahr, prioritaet, aktiv, bezeichnung FROM police_fahndung WHERE id = ?',
{ fahndungId })
if not fahndung or not fahndung[1] then return nil, 'Fahndung nicht gefunden.' end
if fahndung[1].aktiv == 0 then return nil, 'Aufgehobene Fahndungen können nicht geändert werden.' end
local gefahr = GEFAHRSTUFEN[daten.gefahr] and daten.gefahr or fahndung[1].gefahr
local prioritaet = math.max(1, math.min(4, tonumber(daten.prioritaet) or fahndung[1].prioritaet))
MySQL.update.await([[
UPDATE police_fahndung SET grund = ?, gefahr = ?, prioritaet = ?, hinweise = ?, bild = ?
WHERE id = ?
]], {
type(daten.grund) == 'string' and daten.grund:sub(1, 512) or nil,
gefahr, prioritaet,
type(daten.hinweise) == 'string' and daten.hinweise or nil,
type(daten.bild) == 'string' and daten.bild:sub(1, 2000) or nil,
fahndungId,
})
Protokoll.Schreibe(beamter, 'fahndung', 'Fahndung geändert',
{ typ = 'fahndung', id = fahndungId }, { gefahr = gefahr, prioritaet = prioritaet })
-- Bei Höherstufung erneut alarmieren
if gefahr ~= fahndung[1].gefahr and (gefahr == 'gefaehrlich' or gefahr == 'bewaffnet') then
Hinweise.Erstelle(nil, {
kategorie = 'fahndung',
prioritaet = gefahr == 'gefaehrlich' and 'kritisch' or 'hoch',
titel = ('Gefahrenstufe erhöht: %s'):format(fahndung[1].bezeichnung),
text = ('Neue Einstufung: %s'):format(gefahr),
zielTyp = 'fahndung', zielId = fahndungId,
})
end
return { ok = true }
end)
-- =====================================================================
-- AUFHEBEN
--
-- Eine Fahndung endet nie von selbst — auch nicht nach einer Festnahme.
-- =====================================================================
Kern.RegistriereAktion('fahndung', 'aufheben', 'fahndung.aufheben', function(quelle, beamter, daten)
local fahndungId = tonumber(daten.id)
if not fahndungId then return nil, 'Ungültige Kennung.' end
local grund = type(daten.grund) == 'string'
and daten.grund:gsub('^%s+', ''):gsub('%s+$', '') or ''
if #grund < 3 then return nil, 'Bitte einen Grund für die Aufhebung angeben.' end
local fahndung = MySQL.query.await(
'SELECT bezeichnung, aktiv FROM police_fahndung WHERE id = ?', { fahndungId })
if not fahndung or not fahndung[1] then return nil, 'Fahndung nicht gefunden.' end
if fahndung[1].aktiv == 0 then return nil, 'Diese Fahndung ist bereits aufgehoben.' end
MySQL.update.await([[
UPDATE police_fahndung
SET aktiv = 0, aufgehoben_von = ?, aufgehoben_am = NOW()
WHERE id = ?
]], { beamter.charakterId, fahndungId })
Protokoll.Schreibe(beamter, 'fahndung', 'Fahndung aufgehoben',
{ typ = 'fahndung', id = fahndungId },
{ bezeichnung = fahndung[1].bezeichnung }, grund)
Hinweise.Erstelle(nil, {
kategorie = 'fahndung', prioritaet = 'niedrig',
titel = ('Fahndung aufgehoben: %s'):format(fahndung[1].bezeichnung),
text = ('%s · durch %s'):format(grund, beamter.dienstnummer),
})
Kern.Loese('fahndung.aufgehoben', fahndungId, beamter)
return { ok = true }
end)
-- =====================================================================
-- ZIELSUCHE FÜR DIE OBERFLÄCHE
-- =====================================================================
Kern.RegistriereAktion('fahndung', 'zielSuche', 'fahndung.erstellen', function(quelle, beamter, daten)
local begriff = type(daten.begriff) == 'string' and daten.begriff or ''
if #begriff < 2 then return { treffer = {} } end
local muster = '%' .. begriff .. '%'
if daten.typ == 'fahrzeug' then
local fahrzeuge = MySQL.query.await([[
SELECT v.plate, v.model, v.props, c.firstname, c.lastname
FROM aether_vehicles v
LEFT JOIN aether_characters c ON c.id = v.owner
WHERE UPPER(v.plate) LIKE ? OR v.model LIKE ?
LIMIT 20
]], { muster:upper(), muster }) or {}
local treffer = {}
for _, fahrzeug in ipairs(fahrzeuge) do
local name = fahrzeug.model
if fahrzeug.props then
local erfolg, props = pcall(json.decode, fahrzeug.props)
if erfolg and type(props) == 'table' and props.name then name = props.name end
end
treffer[#treffer + 1] = {
id = fahrzeug.plate, titel = fahrzeug.plate .. ' · ' .. name,
unter = 'Halter: ' .. ((fahrzeug.firstname or 'unbekannt') .. ' ' .. (fahrzeug.lastname or '')),
}
end
return { treffer = treffer }
end
local personen = MySQL.query.await([[
SELECT id, firstname, lastname, dob FROM aether_characters
WHERE deleted = 0 AND (firstname LIKE ? OR lastname LIKE ?
OR CONCAT(firstname, ' ', lastname) LIKE ?)
LIMIT 20
]], { muster, muster, muster }) or {}
local treffer = {}
for _, person in ipairs(personen) do
treffer[#treffer + 1] = {
id = tostring(person.id),
titel = person.firstname .. ' ' .. person.lastname,
unter = 'geb. ' .. tostring(person.dob):sub(1, 10),
}
end
return { treffer = treffer }
end)
-- =====================================================================
-- EXPORT — Auskunft für andere Module
-- =====================================================================
--- Besteht eine aktive Fahndung gegen ein Ziel?
exports('BestehtFahndung', function(typ, zielId)
local treffer = MySQL.scalar.await(
'SELECT id FROM police_fahndung WHERE typ = ? AND ziel_id = ? AND aktiv = 1',
{ typ, tostring(zielId) })
return treffer ~= nil
end)