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

358 lines
14 KiB
Lua

-- =====================================================================
-- aether-police / Modul: Administration
--
-- Drei getrennte Bereiche mit je eigenem Recht:
-- * Module (admin.module) — Bausteine an- und abschalten
-- * Katalog (katalog.bearbeiten) — Delikte und ihre Vorschlagswerte
-- * Protokoll (protokoll.lesen) — wer hat wann was getan
--
-- Auch hier gilt: der Katalog liefert VORSCHLÄGE. Was hier eingetragen
-- wird, ist nie eine Strafe, sondern ein Richtwert, von dem ein Beamter
-- jederzeit begründet abweichen darf.
--
-- Nichts wird gelöscht. Delikte werden stillgelegt, damit alte Anzeigen
-- nachvollziehbar bleiben.
-- =====================================================================
Kern.RegistriereModul({
id = 'admin', name = 'Administration', icon = '⚙',
reihenfolge = 14,
-- Ein Recht aus dieser Liste genügt zum Öffnen; die einzelnen
-- Bereiche prüfen danach noch einmal getrennt.
recht = { 'admin.module', 'katalog.bearbeiten', 'protokoll.lesen' },
beschreibung = 'Module, Strafenkatalog und Protokoll',
})
-- Module, die sich nicht abschalten lassen — sonst sperrt man sich aus
local UNVERZICHTBAR = { admin = true, dashboard = true }
-- Schlüssel, unter dem die abgeschalteten Module gespeichert werden
local SCHLUESSEL_AUS = 'module_abgeschaltet'
-- ---------------------------------------------------------------------
-- Einstellungen lesen und schreiben
-- ---------------------------------------------------------------------
--- Liest einen Wert aus police_einstellung
--- @param schluessel string Kennung
--- @return any|nil
local function LeseEinstellung(schluessel)
local roh = MySQL.scalar.await(
'SELECT wert FROM police_einstellung WHERE schluessel = ?', { schluessel })
if not roh then return nil end
local erfolg, wert = pcall(json.decode, roh)
return erfolg and wert or nil
end
--- Schreibt einen Wert nach police_einstellung
--- @param schluessel string Kennung
--- @param wert any Beliebiger Wert
local function SchreibeEinstellung(schluessel, wert)
MySQL.query.await([[
INSERT INTO police_einstellung (schluessel, wert) VALUES (?, ?)
ON DUPLICATE KEY UPDATE wert = VALUES(wert)
]], { schluessel, json.encode(wert) })
end
-- ---------------------------------------------------------------------
-- Beim Start: gespeicherte Modulzustände wiederherstellen
-- Läuft erst, wenn alle Moduldateien geladen und registriert sind.
-- ---------------------------------------------------------------------
CreateThread(function()
Wait(0)
local abgeschaltet = LeseEinstellung(SCHLUESSEL_AUS)
if type(abgeschaltet) ~= 'table' then return end
for _, modulId in ipairs(abgeschaltet) do
if not UNVERZICHTBAR[modulId] and Kern.KenntModul(modulId) then
Kern.SetzeModulAktiv(modulId, false)
Aether.Debug(('Polizei-Modul abgeschaltet (gespeichert): %s'):format(modulId))
end
end
end)
-- =====================================================================
-- MODULE
-- =====================================================================
--- Welche Bereiche darf dieser Beamte sehen? Steuert die Reiter der Oberfläche.
Kern.RegistriereAktion('admin', 'bereiche', nil, function(quelle, beamter)
return {
module = Rechte.Hat(beamter, 'admin.module'),
katalog = Rechte.Hat(beamter, 'katalog.bearbeiten'),
protokoll = Rechte.Hat(beamter, 'protokoll.lesen'),
}
end)
Kern.RegistriereAktion('admin', 'module', 'admin.module', function(quelle, beamter)
local module = Kern.AlleModule()
for _, modul in ipairs(module) do
modul.unverzichtbar = UNVERZICHTBAR[modul.id] == true
-- Welches Recht öffnet dieses Modul, und wie heißt es im Klartext?
modul.rechtName = nil
if modul.recht then
for _, gruppe in ipairs(Polizei.Rechte) do
for _, recht in ipairs(gruppe.eintraege) do
if recht.id == modul.recht then modul.rechtName = recht.name end
end
end
end
end
-- Werte aus config.lua zur Ansicht — sie leben in der Datei, nicht in der DB
local konfiguration = {
{ name = 'Behörde', wert = Polizei.Config.Behoerde },
{ name = 'Kürzel', wert = Polizei.Config.Kuerzel },
{ name = 'Terminals', wert = tostring(#Polizei.Config.Terminals) },
{ name = 'Abteilungen', wert = tostring(#Polizei.Config.Abteilungen) },
{ name = 'Rollen', wert = tostring(#Polizei.Rollen) },
{ name = 'Rechte', wert = tostring(#Polizei.AlleRechte()) },
{ name = 'Fristprüfung alle', wert = (Polizei.Config.Erinnerungen.pruefIntervall / 1000)
.. ' Sekunden' },
}
return {
module = module,
konfiguration = konfiguration,
beamteImDienst = (function()
local anzahl = 0
for _ in pairs(Dienst.AlleImDienst()) do anzahl = anzahl + 1 end
return anzahl
end)(),
}
end)
Kern.RegistriereAktion('admin', 'modulSchalten', 'admin.module', function(quelle, beamter, daten)
local modulId = tostring(daten.id or '')
local aktiv = daten.aktiv == true
if not Kern.KenntModul(modulId) then return nil, 'Unbekanntes Modul.' end
if UNVERZICHTBAR[modulId] then
return nil, 'Dieses Modul lässt sich nicht abschalten.'
end
Kern.SetzeModulAktiv(modulId, aktiv)
-- Zustand merken, damit er einen Neustart übersteht
local abgeschaltet, name = {}, modulId
for _, modul in ipairs(Kern.AlleModule()) do
if not modul.aktiv then abgeschaltet[#abgeschaltet + 1] = modul.id end
if modul.id == modulId then name = modul.name end
end
SchreibeEinstellung(SCHLUESSEL_AUS, abgeschaltet)
Protokoll.Schreibe(beamter, 'admin',
aktiv and 'Modul eingeschaltet' or 'Modul abgeschaltet',
{ typ = 'modul', id = modulId }, nil, daten.grund)
-- Alle im Dienst darüber informieren. Die Seitenleiste aktualisiert sich
-- beim nächsten Öffnen des Tablets — laufende Arbeit wird nicht unterbrochen.
Hinweise.Erstelle(nil, {
kategorie = 'system', prioritaet = 'niedrig',
titel = aktiv and 'Modul eingeschaltet' or 'Modul abgeschaltet',
text = ('„%s" — beim nächsten Öffnen des Tablets sichtbar.'):format(name),
})
Kern.Loese('admin.modulGeschaltet', modulId, aktiv)
return { ok = true }
end)
-- =====================================================================
-- STRAFENKATALOG
--
-- Die Werte hier sind Vorschläge. Sie werden nie automatisch verhängt.
-- =====================================================================
Kern.RegistriereAktion('admin', 'katalog', 'katalog.bearbeiten', function(quelle, beamter, daten)
local bedingungen, werte = { '1=1' }, {}
if not daten.stillgelegt then
bedingungen[#bedingungen + 1] = 'aktiv = 1'
end
if daten.kategorie and daten.kategorie ~= '' then
bedingungen[#bedingungen + 1] = 'kategorie = ?'
werte[#werte + 1] = daten.kategorie
end
if type(daten.begriff) == 'string' and #daten.begriff >= 2 then
bedingungen[#bedingungen + 1] =
'(kennung LIKE ? OR bezeichnung LIKE ? OR paragraph LIKE ?)'
local muster = '%' .. daten.begriff .. '%'
werte[#werte + 1] = muster
werte[#werte + 1] = muster
werte[#werte + 1] = muster
end
local delikte = MySQL.query.await(([[
SELECT id, kennung, kategorie, bezeichnung, paragraph, haft, geld,
hinweis, aktiv, sortierung
FROM police_katalog
WHERE %s
ORDER BY kategorie, sortierung, kennung
LIMIT 500
]]):format(table.concat(bedingungen, ' AND ')), werte) or {}
for _, delikt in ipairs(delikte) do
delikt.aktiv = delikt.aktiv == 1
end
local kategorien = MySQL.query.await(
'SELECT DISTINCT kategorie FROM police_katalog ORDER BY kategorie') or {}
return {
delikte = delikte,
kategorien = kategorien,
stillgelegt = daten.stillgelegt == true,
gesamt = MySQL.scalar.await('SELECT COUNT(*) FROM police_katalog WHERE aktiv = 1') or 0,
}
end)
Kern.RegistriereAktion('admin', 'katalogSpeichern', 'katalog.bearbeiten', function(quelle, beamter, daten)
local kennung = type(daten.kennung) == 'string'
and daten.kennung:gsub('%s', ''):upper() or ''
local bezeichnung = type(daten.bezeichnung) == 'string' and daten.bezeichnung:sub(1, 160) or ''
local kategorie = type(daten.kategorie) == 'string' and daten.kategorie:sub(1, 48) or ''
if #kennung < 2 then return nil, 'Bitte eine Kennung angeben.' end
if #bezeichnung < 3 then return nil, 'Bitte eine Bezeichnung angeben.' end
if #kategorie < 2 then return nil, 'Bitte eine Kategorie angeben.' end
local haft = math.max(0, math.floor(tonumber(daten.haft) or 0))
local geld = math.max(0, math.floor(tonumber(daten.geld) or 0))
if haft > 100000 or geld > 10000000 then
return nil, 'Der Vorschlagswert ist unplausibel hoch.'
end
local id = tonumber(daten.id)
local hinweis = type(daten.hinweis) == 'string' and daten.hinweis:sub(1, 255) or nil
local sortierung = math.floor(tonumber(daten.sortierung) or 0)
-- Kennung darf nur einmal vergeben sein
local belegt = MySQL.scalar.await(
'SELECT id FROM police_katalog WHERE kennung = ? AND (? IS NULL OR id <> ?)',
{ kennung, id, id })
if belegt then return nil, 'Diese Kennung ist bereits vergeben.' end
if id then
local alt = MySQL.query.await(
'SELECT kennung, bezeichnung, haft, geld FROM police_katalog WHERE id = ?', { id })
if not alt or not alt[1] then return nil, 'Delikt nicht gefunden.' end
MySQL.update.await([[
UPDATE police_katalog
SET kennung = ?, kategorie = ?, bezeichnung = ?, paragraph = ?,
haft = ?, geld = ?, hinweis = ?, sortierung = ?
WHERE id = ?
]], { kennung, kategorie, bezeichnung, daten.paragraph, haft, geld,
hinweis, sortierung, id })
Protokoll.Schreibe(beamter, 'admin', 'Delikt geändert',
{ typ = 'katalog', id = id },
{ kennung = kennung,
haftAlt = alt[1].haft, haftNeu = haft,
geldAlt = alt[1].geld, geldNeu = geld })
else
id = MySQL.insert.await([[
INSERT INTO police_katalog
(kennung, kategorie, bezeichnung, paragraph, haft, geld, hinweis, sortierung)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
]], { kennung, kategorie, bezeichnung, daten.paragraph, haft, geld,
hinweis, sortierung })
Protokoll.Schreibe(beamter, 'admin', 'Delikt angelegt',
{ typ = 'katalog', id = id },
{ kennung = kennung, haft = haft, geld = geld })
end
Kern.Loese('katalog.geaendert', id, beamter)
return { id = id }
end)
Kern.RegistriereAktion('admin', 'katalogSchalten', 'katalog.bearbeiten', function(quelle, beamter, daten)
local id = tonumber(daten.id)
if not id then return nil, 'Ungültige Kennung.' end
local aktiv = daten.aktiv == true
local delikt = MySQL.query.await(
'SELECT kennung, bezeichnung FROM police_katalog WHERE id = ?', { id })
if not delikt or not delikt[1] then return nil, 'Delikt nicht gefunden.' end
-- Stilllegen statt löschen: bestehende Anzeigen bleiben nachvollziehbar
MySQL.update.await('UPDATE police_katalog SET aktiv = ? WHERE id = ?',
{ aktiv and 1 or 0, id })
Protokoll.Schreibe(beamter, 'admin',
aktiv and 'Delikt wieder freigegeben' or 'Delikt stillgelegt',
{ typ = 'katalog', id = id },
{ kennung = delikt[1].kennung }, daten.grund)
return { ok = true }
end)
-- =====================================================================
-- PROTOKOLL
--
-- Reine Auskunft. Einträge lassen sich weder ändern noch entfernen.
-- =====================================================================
Kern.RegistriereAktion('admin', 'protokoll', 'protokoll.lesen', function(quelle, beamter, daten)
local filter = { limit = math.min(tonumber(daten.limit) or 100, 500) }
if daten.modul and daten.modul ~= '' then filter.modul = daten.modul end
-- Nach Beamtem filtern: über die Dienstnummer, nicht über eine ID.
-- Eine unbekannte Nummer liefert eine leere Liste, keinen Fehler —
-- sonst bricht die Ansicht schon beim Tippen ab.
local unbekannteNummer = false
if type(daten.dienstnummer) == 'string' and #daten.dienstnummer >= 2 then
local charakterId = MySQL.scalar.await(
'SELECT `character` FROM police_personal WHERE dienstnummer = ?',
{ daten.dienstnummer:upper() })
if charakterId then
filter.charakterId = charakterId
else
unbekannteNummer = true
end
end
local eintraege = unbekannteNummer and {} or Protokoll.Lies(filter)
for _, eintrag in ipairs(eintraege) do
eintrag.name = eintrag.firstname
and (eintrag.firstname .. ' ' .. eintrag.lastname) or 'System'
eintrag.firstname, eintrag.lastname = nil, nil
-- Details lesbar machen
if eintrag.details then
local erfolg, wert = pcall(json.decode, eintrag.details)
eintrag.details = erfolg and wert or nil
end
end
-- Welche Module tauchen im Protokoll überhaupt auf?
local module = MySQL.query.await(
'SELECT DISTINCT modul FROM police_protokoll ORDER BY modul') or {}
return {
eintraege = eintraege,
module = module,
unbekannteNummer = unbekannteNummer,
gesamt = MySQL.scalar.await('SELECT COUNT(*) FROM police_protokoll') or 0,
}
end)
-- ---------------------------------------------------------------------
-- Schnittstelle für andere Ressourcen
-- ---------------------------------------------------------------------
--- Liefert alle registrierten Module mit ihrem Zustand
--- @return table
exports('HoleModule', function()
return Kern.AlleModule()
end)