- 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>
400 lines
16 KiB
Lua
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)
|