- 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>
358 lines
14 KiB
Lua
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)
|