diff --git a/backend/admin_milestone.go b/backend/admin_milestone.go
index ca3d3bb..9e14bd1 100644
--- a/backend/admin_milestone.go
+++ b/backend/admin_milestone.go
@@ -52,6 +52,28 @@ type milestoneCredentials struct {
SkipTLSVerify bool
}
+type milestoneSyncResult struct {
+ TokenReceived bool `json:"tokenReceived"`
+ TokenType string `json:"tokenType"`
+ TokenExpiresAt time.Time `json:"tokenExpiresAt"`
+ RecordingServerID string `json:"recordingServerId"`
+ RecordingServerName string `json:"recordingServerName"`
+ RecordingServerVersion string `json:"recordingServerVersion"`
+ HardwareCount int `json:"hardwareCount"`
+}
+
+type milestoneSyncEvent struct {
+ Type string `json:"type"`
+ Message string `json:"message,omitempty"`
+ TokenType string `json:"tokenType,omitempty"`
+ TokenExpiresAt *time.Time `json:"tokenExpiresAt,omitempty"`
+ RecordingServerID string `json:"recordingServerId,omitempty"`
+ RecordingServerName string `json:"recordingServerName,omitempty"`
+ RecordingServerVersion string `json:"recordingServerVersion,omitempty"`
+ HardwareCount int `json:"hardwareCount,omitempty"`
+ Settings *milestoneSettingsResponse `json:"settings,omitempty"`
+}
+
type milestoneTokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
@@ -71,6 +93,68 @@ type milestoneRecordingServer struct {
RecordingServerVersion string `json:"recordingServerVersion"`
}
+type milestoneHardwareResponse struct {
+ Array []milestoneHardware `json:"array"`
+}
+
+type milestoneHardware struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ DisplayName string `json:"displayName"`
+ DeviceName string `json:"deviceName"`
+ Address string `json:"address"`
+ Enabled *bool `json:"enabled"`
+ Disabled *bool `json:"disabled"`
+ Relations milestoneRelations `json:"relations"`
+}
+
+type milestoneRelations struct {
+ Parent milestoneRelationRef `json:"parent"`
+ Self milestoneRelationRef `json:"self"`
+}
+
+type milestoneRelationRef struct {
+ Type string `json:"type"`
+ ID string `json:"id"`
+}
+
+type milestoneHardwareDriverSettingsResponse struct {
+ Array []milestoneHardwareDriverSettingsItem `json:"array"`
+}
+
+type milestoneHardwareDriverSettingsItem struct {
+ DisplayName string `json:"displayName"`
+ HardwareDriverSettings milestoneHardwareDriverSettings `json:"hardwareDriverSettings"`
+ Relations milestoneRelations `json:"relations"`
+}
+
+type milestoneHardwareDriverSettings struct {
+ DetectedModelName string `json:"detectedModelName"`
+ ProductID string `json:"productID"`
+ MacAddress string `json:"macAddress"`
+ IPAddress string `json:"ipAddress"`
+ Address string `json:"address"`
+ HostName string `json:"hostName"`
+ DeviceModelName string `json:"deviceModelName"`
+ DeviceManufacturer string `json:"deviceManufacturer"`
+ FirmwareVersion string `json:"firmwareVersion"`
+ SerialNumber string `json:"serialNumber"`
+}
+
+type milestoneHardwareDevice struct {
+ HardwareID string
+ RecordingServerID string
+ DisplayName string
+ DeviceName string
+ Manufacturer string
+ MacAddress string
+ IPAddress string
+ DeviceModelName string
+ SerialNumber string
+ FirmwareVersion string
+ Enabled bool
+}
+
func (s *Server) handleGetMilestoneSettings(w http.ResponseWriter, r *http.Request) {
settings, err := s.getMilestoneSettings(r.Context())
if errors.Is(err, pgx.ErrNoRows) {
@@ -207,14 +291,30 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re
return
}
+ if r.URL.Query().Get("sync") == "false" {
+ settings, err := s.getMilestoneSettings(r.Context())
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Milestone-Einstellungen wurden gespeichert, konnten aber nicht neu geladen werden")
+ return
+ }
+
+ writeJSON(w, http.StatusOK, map[string]any{
+ "settings": settings,
+ })
+ return
+ }
+
syncWarning := ""
+ var syncResult milestoneSyncResult
+
credentials, err := s.getMilestoneCredentials(r.Context())
if err == nil && credentials.Host != "" && credentials.Username != "" && credentials.Password != "" {
- if err := s.refreshMilestoneTokenAndServerID(r.Context(), credentials); err != nil {
+ syncResult, err = s.refreshMilestoneTokenAndServerID(r.Context(), credentials)
+ if err != nil {
log.Printf("Milestone sync after settings save failed: %v", err)
- syncWarning = "Milestone-Zugangsdaten wurden gespeichert, aber Token oder Server-ID konnten nicht abgerufen werden."
+ syncWarning = "Milestone-Zugangsdaten wurden gespeichert, aber Token, Recording-Server oder Hardware konnten nicht vollständig abgerufen werden."
}
} else {
syncWarning = "Milestone-Zugangsdaten wurden gespeichert, konnten aber nicht vollständig geprüft werden."
@@ -232,6 +332,8 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re
if syncWarning != "" {
response["warning"] = syncWarning
+ } else if syncResult.TokenReceived {
+ response["sync"] = syncResult
}
writeJSON(w, http.StatusOK, response)
@@ -254,10 +356,11 @@ func (s *Server) handleFetchMilestoneToken(w http.ResponseWriter, r *http.Reques
return
}
- if err := s.refreshMilestoneTokenAndServerID(r.Context(), credentials); err != nil {
+ syncResult, err := s.refreshMilestoneTokenAndServerID(r.Context(), credentials)
+ if err != nil {
log.Printf("Milestone token/server sync failed: %v", err)
- writeError(w, http.StatusBadGateway, "Milestone-Token oder Server-ID konnten nicht abgerufen werden")
+ writeError(w, http.StatusBadGateway, "Milestone-Token, Recording-Server oder Hardware konnten nicht abgerufen werden")
return
}
@@ -269,9 +372,83 @@ func (s *Server) handleFetchMilestoneToken(w http.ResponseWriter, r *http.Reques
writeJSON(w, http.StatusOK, map[string]any{
"settings": settings,
+ "sync": syncResult,
})
}
+func (s *Server) handleFetchMilestoneTokenStream(w http.ResponseWriter, r *http.Request) {
+ credentials, err := s.getMilestoneCredentials(r.Context())
+ if errors.Is(err, pgx.ErrNoRows) {
+ writeError(w, http.StatusBadRequest, "Milestone-Einstellungen sind noch nicht hinterlegt")
+ return
+ }
+
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Milestone-Zugangsdaten konnten nicht geladen werden")
+ return
+ }
+
+ if credentials.Host == "" || credentials.Username == "" || credentials.Password == "" {
+ writeError(w, http.StatusBadRequest, "Milestone-Zugangsdaten sind unvollständig")
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/x-ndjson")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("X-Accel-Buffering", "no")
+
+ _, err = s.refreshMilestoneTokenAndServerIDWithProgress(
+ r.Context(),
+ credentials,
+ func(event milestoneSyncEvent) {
+ writeMilestoneSyncEvent(w, event)
+ },
+ )
+ if err != nil {
+ log.Printf("Milestone token/server sync stream failed: %v", err)
+
+ writeMilestoneSyncEvent(w, milestoneSyncEvent{
+ Type: "error",
+ Message: "Milestone-Token, Recording-Server oder Hardware konnten nicht abgerufen werden",
+ })
+ return
+ }
+
+ settings, err := s.getMilestoneSettings(r.Context())
+ if err != nil {
+ writeMilestoneSyncEvent(w, milestoneSyncEvent{
+ Type: "error",
+ Message: "Milestone-Daten wurden gespeichert, konnten aber nicht neu geladen werden",
+ })
+ return
+ }
+
+ writeMilestoneSyncEvent(w, milestoneSyncEvent{
+ Type: "done",
+ Settings: &settings,
+ })
+}
+
+func writeMilestoneSyncEvent(w http.ResponseWriter, event milestoneSyncEvent) {
+ if err := json.NewEncoder(w).Encode(event); err != nil {
+ log.Printf("Milestone sync stream event could not be written: %v", err)
+ return
+ }
+
+ if flusher, ok := w.(http.Flusher); ok {
+ flusher.Flush()
+ }
+}
+
+func emitMilestoneSyncEvent(
+ progress func(milestoneSyncEvent),
+ event milestoneSyncEvent,
+) {
+ if progress != nil {
+ progress(event)
+ }
+}
+
func (s *Server) getMilestoneSettings(ctx context.Context) (milestoneSettingsResponse, error) {
var settings milestoneSettingsResponse
var updatedAt time.Time
@@ -331,15 +508,25 @@ func (s *Server) getMilestoneSettings(ctx context.Context) (milestoneSettingsRes
func (s *Server) refreshMilestoneTokenAndServerID(
ctx context.Context,
credentials milestoneCredentials,
-) error {
+) (milestoneSyncResult, error) {
+ return s.refreshMilestoneTokenAndServerIDWithProgress(ctx, credentials, nil)
+}
+
+func (s *Server) refreshMilestoneTokenAndServerIDWithProgress(
+ ctx context.Context,
+ credentials milestoneCredentials,
+ progress func(milestoneSyncEvent),
+) (milestoneSyncResult, error) {
+ var syncResult milestoneSyncResult
+
tokenResponse, err := s.requestMilestoneToken(ctx, credentials)
if err != nil {
- return err
+ return syncResult, err
}
accessToken := strings.TrimSpace(tokenResponse.AccessToken)
if accessToken == "" {
- return errors.New("milestone returned empty access token")
+ return syncResult, errors.New("milestone returned empty access token")
}
tokenType := strings.TrimSpace(tokenResponse.TokenType)
@@ -354,6 +541,16 @@ func (s *Server) refreshMilestoneTokenAndServerID(
tokenExpiresAt := time.Now().Add(time.Duration(expiresIn) * time.Second)
+ syncResult.TokenReceived = true
+ syncResult.TokenType = tokenType
+ syncResult.TokenExpiresAt = tokenExpiresAt
+
+ emitMilestoneSyncEvent(progress, milestoneSyncEvent{
+ Type: "token",
+ TokenType: tokenType,
+ TokenExpiresAt: &tokenExpiresAt,
+ })
+
serverID, serverName, serverVersion, err := s.requestMilestoneServerID(
ctx,
credentials.Host,
@@ -361,14 +558,41 @@ func (s *Server) refreshMilestoneTokenAndServerID(
accessToken,
credentials.SkipTLSVerify,
)
-
if err != nil {
- return err
+ return syncResult, err
}
+ syncResult.RecordingServerID = serverID
+ syncResult.RecordingServerName = serverName
+ syncResult.RecordingServerVersion = serverVersion
+
+ emitMilestoneSyncEvent(progress, milestoneSyncEvent{
+ Type: "recordingServer",
+ RecordingServerID: serverID,
+ RecordingServerName: serverName,
+ RecordingServerVersion: serverVersion,
+ })
+
+ hardwareDevices, err := s.requestMilestoneHardwareDevices(
+ ctx,
+ credentials.Host,
+ serverID,
+ accessToken,
+ credentials.SkipTLSVerify,
+ )
+ if err != nil {
+ return syncResult, err
+ }
+
+ tx, err := s.db.Begin(ctx)
+ if err != nil {
+ return syncResult, err
+ }
+ defer tx.Rollback(ctx)
+
encryptionKey := s.milestoneEncryptionKey()
- _, err = s.db.Exec(
+ _, err = tx.Exec(
ctx,
`
UPDATE milestone_settings
@@ -392,8 +616,33 @@ func (s *Server) refreshMilestoneTokenAndServerID(
serverName,
serverVersion,
)
+ if err != nil {
+ return syncResult, err
+ }
- return err
+ syncedHardwareCount, err := syncMilestoneHardwareDevices(ctx, tx, hardwareDevices)
+ if err != nil {
+ return syncResult, err
+ }
+
+ syncResult.HardwareCount = syncedHardwareCount
+
+ if err := tx.Commit(ctx); err != nil {
+ return syncResult, err
+ }
+
+ emitMilestoneSyncEvent(progress, milestoneSyncEvent{
+ Type: "hardware",
+ HardwareCount: syncResult.HardwareCount,
+ })
+
+ log.Printf(
+ "Milestone sync completed successfully: recordingServerId=%s hardwareDevices=%d",
+ serverID,
+ syncedHardwareCount,
+ )
+
+ return syncResult, nil
}
func (s *Server) requestMilestoneServerID(
@@ -498,6 +747,269 @@ func (s *Server) requestMilestoneServerID(
return recordingServerID, recordingServerName, recordingServerVersion, nil
}
+func (s *Server) requestMilestoneHardwareDevices(
+ ctx context.Context,
+ host string,
+ recordingServerID string,
+ accessToken string,
+ skipTLSVerify bool,
+) ([]milestoneHardwareDevice, error) {
+ hardwareItems, err := s.requestMilestoneHardware(
+ ctx,
+ host,
+ recordingServerID,
+ accessToken,
+ skipTLSVerify,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ result := make([]milestoneHardwareDevice, 0, len(hardwareItems))
+
+ for _, hardware := range hardwareItems {
+ hardwareID := strings.TrimSpace(hardware.ID)
+ if hardwareID == "" {
+ hardwareID = strings.TrimSpace(hardware.Relations.Self.ID)
+ }
+
+ if hardwareID == "" {
+ log.Printf("Milestone hardware skipped: missing hardware id")
+ continue
+ }
+
+ settings, err := s.requestMilestoneHardwareDriverSettings(
+ ctx,
+ host,
+ hardwareID,
+ accessToken,
+ skipTLSVerify,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ displayName := strings.TrimSpace(hardware.DisplayName)
+ if displayName == "" {
+ displayName = strings.TrimSpace(hardware.Name)
+ }
+
+ model := strings.TrimSpace(settings.DeviceModelName)
+ if model == "" {
+ model = strings.TrimSpace(settings.DetectedModelName)
+ }
+
+ manufacturer := normalizeMilestoneManufacturer(
+ settings.DeviceManufacturer,
+ model,
+ )
+
+ if manufacturer == "" {
+ manufacturer = normalizeMilestoneManufacturer(
+ settings.ProductID,
+ model,
+ )
+ }
+
+ if manufacturer == "" {
+ manufacturer = extractMilestoneManufacturerFromName(model)
+ }
+
+ deviceName := strings.TrimSpace(hardware.DeviceName)
+ if deviceName == "" {
+ deviceName = displayName
+ }
+
+ enabled := true
+
+ if hardware.Enabled != nil {
+ enabled = *hardware.Enabled
+ }
+
+ if hardware.Disabled != nil {
+ enabled = !*hardware.Disabled
+ }
+
+ ipAddress := extractMilestoneHardwareIPAddress(hardware.Address)
+
+ result = append(result, milestoneHardwareDevice{
+ HardwareID: hardwareID,
+ RecordingServerID: recordingServerID,
+ DisplayName: displayName,
+ DeviceName: deviceName,
+ Manufacturer: manufacturer,
+ MacAddress: strings.TrimSpace(settings.MacAddress),
+ IPAddress: ipAddress,
+ DeviceModelName: model,
+ SerialNumber: strings.TrimSpace(settings.SerialNumber),
+ FirmwareVersion: strings.TrimSpace(settings.FirmwareVersion),
+ Enabled: enabled,
+ })
+ }
+
+ log.Printf(
+ "Milestone hardware loaded successfully: recordingServerId=%s count=%d",
+ recordingServerID,
+ len(result),
+ )
+
+ return result, nil
+}
+
+func extractMilestoneHardwareIPAddress(value string) string {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return ""
+ }
+
+ parsedURL, err := url.Parse(value)
+ if err == nil && parsedURL.Hostname() != "" {
+ return parsedURL.Hostname()
+ }
+
+ value = strings.TrimPrefix(value, "http://")
+ value = strings.TrimPrefix(value, "https://")
+ value = strings.TrimRight(value, "/")
+
+ if host, _, found := strings.Cut(value, ":"); found {
+ return strings.TrimSpace(host)
+ }
+
+ return strings.TrimSpace(value)
+}
+
+func (s *Server) requestMilestoneHardware(
+ ctx context.Context,
+ host string,
+ recordingServerID string,
+ accessToken string,
+ skipTLSVerify bool,
+) ([]milestoneHardware, error) {
+ requestURL := strings.TrimRight(host, "/") +
+ "/API/rest/v1/recordingServers/" +
+ url.PathEscape(recordingServerID) +
+ "/hardware?disabled"
+
+ log.Printf(
+ "Requesting Milestone hardware: url=%s skipTLSVerify=%t",
+ requestURL,
+ skipTLSVerify,
+ )
+
+ body, err := requestMilestoneJSON(
+ ctx,
+ requestURL,
+ accessToken,
+ skipTLSVerify,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ var hardwareResponse milestoneHardwareResponse
+ if err := json.Unmarshal(body, &hardwareResponse); err != nil {
+ return nil, fmt.Errorf(
+ "milestone hardware response could not be parsed: body=%s error=%w",
+ truncateMilestoneLogBody(body),
+ err,
+ )
+ }
+
+ return hardwareResponse.Array, nil
+}
+
+func (s *Server) requestMilestoneHardwareDriverSettings(
+ ctx context.Context,
+ host string,
+ hardwareID string,
+ accessToken string,
+ skipTLSVerify bool,
+) (milestoneHardwareDriverSettings, error) {
+ requestURL := strings.TrimRight(host, "/") +
+ "/API/rest/v1/hardware/" +
+ url.PathEscape(hardwareID) +
+ "/hardwareDriverSettings"
+
+ log.Printf(
+ "Requesting Milestone hardware driver settings: url=%s skipTLSVerify=%t",
+ requestURL,
+ skipTLSVerify,
+ )
+
+ body, err := requestMilestoneJSON(
+ ctx,
+ requestURL,
+ accessToken,
+ skipTLSVerify,
+ )
+ if err != nil {
+ return milestoneHardwareDriverSettings{}, err
+ }
+
+ var settingsResponse milestoneHardwareDriverSettingsResponse
+ if err := json.Unmarshal(body, &settingsResponse); err != nil {
+ return milestoneHardwareDriverSettings{}, fmt.Errorf(
+ "milestone hardware driver settings response could not be parsed: body=%s error=%w",
+ truncateMilestoneLogBody(body),
+ err,
+ )
+ }
+
+ if len(settingsResponse.Array) == 0 {
+ return milestoneHardwareDriverSettings{}, fmt.Errorf(
+ "milestone returned no hardware driver settings: hardwareId=%s body=%s",
+ hardwareID,
+ truncateMilestoneLogBody(body),
+ )
+ }
+
+ return settingsResponse.Array[0].HardwareDriverSettings, nil
+}
+
+func requestMilestoneJSON(
+ ctx context.Context,
+ requestURL string,
+ accessToken string,
+ skipTLSVerify bool,
+) ([]byte, error) {
+ request, err := http.NewRequestWithContext(
+ ctx,
+ http.MethodGet,
+ requestURL,
+ nil,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ request.Header.Set("Authorization", "Bearer "+accessToken)
+ request.Header.Set("Accept", "application/json")
+
+ client := milestoneHTTPClient(skipTLSVerify)
+
+ response, err := client.Do(request)
+ if err != nil {
+ return nil, err
+ }
+ defer response.Body.Close()
+
+ body, err := io.ReadAll(response.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ if response.StatusCode < 200 || response.StatusCode >= 300 {
+ return nil, fmt.Errorf(
+ "milestone request failed: url=%s status=%d body=%s",
+ requestURL,
+ response.StatusCode,
+ truncateMilestoneLogBody(body),
+ )
+ }
+
+ return body, nil
+}
+
func (s *Server) getMilestoneCredentials(ctx context.Context) (milestoneCredentials, error) {
var credentials milestoneCredentials
diff --git a/backend/camera_firmware.go b/backend/camera_firmware.go
new file mode 100644
index 0000000..eaa0ea5
--- /dev/null
+++ b/backend/camera_firmware.go
@@ -0,0 +1,1015 @@
+// backend/camera_firmware.go
+
+package main
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "html"
+ "io"
+ "net/http"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+ "unicode"
+
+ "github.com/jackc/pgx/v5"
+)
+
+const cameraFirmwareCacheMaxAge = 24 * time.Hour
+const cameraFirmwareManualCheckCooldown = time.Hour
+
+type cameraFirmwareProvider interface {
+ Supports(manufacturer string, model string) bool
+ CheckLatestFirmware(ctx context.Context, manufacturer string, model string) (cameraFirmwareLatest, error)
+}
+
+type cameraFirmwareLatest struct {
+ LatestVersion string
+ SupportURL string
+ Source string
+ SupportBadges []DeviceSupportBadge
+}
+
+type cameraFirmwareCacheEntry struct {
+ Manufacturer string
+ NormalizedModel string
+ LatestVersion string
+ SupportURL string
+ Source string
+ CheckError string
+ SupportBadges []DeviceSupportBadge
+ CheckedAt time.Time
+}
+
+type cameraFirmwareManualCheckState struct {
+ LastManualCheckedAt *time.Time
+ NextAllowedAt *time.Time
+ CanCheck bool
+ RemainingSeconds int64
+}
+
+type firmwareLookupKey struct {
+ Manufacturer string
+ NormalizedModel string
+ Model string
+}
+
+func cameraFirmwareProviders() []cameraFirmwareProvider {
+ return []cameraFirmwareProvider{
+ axisFirmwareProvider{},
+ }
+}
+
+func (s *Server) enrichDevicesWithFirmwareUpdates(ctx context.Context, devices []Device) {
+ if len(devices) == 0 {
+ return
+ }
+
+ lookupKeys := map[firmwareLookupKey]bool{}
+
+ for _, device := range devices {
+ manufacturer := strings.TrimSpace(device.Manufacturer)
+ model := strings.TrimSpace(device.Model)
+
+ if manufacturer == "" || model == "" {
+ continue
+ }
+
+ provider := findCameraFirmwareProvider(manufacturer, model)
+ if provider == nil {
+ continue
+ }
+
+ lookupKeys[firmwareLookupKey{
+ Manufacturer: normalizeCameraFirmwareKey(manufacturer),
+ NormalizedModel: normalizeCameraFirmwareKey(model),
+ Model: model,
+ }] = true
+ }
+
+ if len(lookupKeys) == 0 {
+ return
+ }
+
+ results := map[firmwareLookupKey]cameraFirmwareCacheEntry{}
+
+ for lookupKey := range lookupKeys {
+ provider := findCameraFirmwareProvider(lookupKey.Manufacturer, lookupKey.Model)
+ if provider == nil {
+ continue
+ }
+
+ cachedEntry, cached, err := s.getCameraFirmwareCache(
+ ctx,
+ lookupKey.Manufacturer,
+ lookupKey.NormalizedModel,
+ )
+ if err != nil {
+ logError("Firmware-Cache konnte nicht geladen werden", err, LogFields{
+ "manufacturer": lookupKey.Manufacturer,
+ "model": lookupKey.Model,
+ })
+ continue
+ }
+
+ if cached && time.Since(cachedEntry.CheckedAt) <= cameraFirmwareCacheMaxAge {
+ results[lookupKey] = cachedEntry
+ continue
+ }
+
+ latest, err := provider.CheckLatestFirmware(
+ ctx,
+ lookupKey.Manufacturer,
+ lookupKey.Model,
+ )
+ if err != nil {
+ logError("Firmware-Version konnte nicht geprüft werden", err, LogFields{
+ "manufacturer": lookupKey.Manufacturer,
+ "model": lookupKey.Model,
+ })
+
+ if cached {
+ results[lookupKey] = cachedEntry
+ }
+
+ _ = s.recordCameraFirmwareCheckError(
+ ctx,
+ lookupKey.Manufacturer,
+ lookupKey.NormalizedModel,
+ err.Error(),
+ )
+
+ continue
+ }
+
+ cacheEntry, err := s.upsertCameraFirmwareCache(
+ ctx,
+ lookupKey.Manufacturer,
+ lookupKey.NormalizedModel,
+ latest,
+ )
+ if err != nil {
+ logError("Firmware-Cache konnte nicht gespeichert werden", err, LogFields{
+ "manufacturer": lookupKey.Manufacturer,
+ "model": lookupKey.Model,
+ "latestVersion": latest.LatestVersion,
+ })
+ continue
+ }
+
+ results[lookupKey] = cacheEntry
+ }
+
+ for index := range devices {
+ device := &devices[index]
+
+ lookupKey := firmwareLookupKey{
+ Manufacturer: normalizeCameraFirmwareKey(device.Manufacturer),
+ NormalizedModel: normalizeCameraFirmwareKey(device.Model),
+ Model: device.Model,
+ }
+
+ cacheEntry, exists := results[lookupKey]
+ if !exists {
+ continue
+ }
+
+ if len(cacheEntry.SupportBadges) > 0 {
+ device.SupportBadges = cacheEntry.SupportBadges
+ }
+
+ if strings.TrimSpace(cacheEntry.LatestVersion) == "" ||
+ strings.TrimSpace(device.FirmwareVersion) == "" {
+ continue
+ }
+
+ available := isFirmwareVersionNewer(
+ cacheEntry.LatestVersion,
+ device.FirmwareVersion,
+ )
+
+ device.FirmwareUpdate = &DeviceFirmwareUpdate{
+ Available: available,
+ CurrentVersion: device.FirmwareVersion,
+ LatestVersion: cacheEntry.LatestVersion,
+ SupportURL: cacheEntry.SupportURL,
+ Source: cacheEntry.Source,
+ CheckedAt: &cacheEntry.CheckedAt,
+ }
+ }
+}
+
+func (s *Server) handleGetCameraFirmwareCheckState(w http.ResponseWriter, r *http.Request) {
+ state, err := s.getCameraFirmwareManualCheckState(r.Context())
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Firmware-Prüfstatus konnte nicht geladen werden")
+ return
+ }
+
+ lastCacheCheckedAt, err := s.getLastCameraFirmwareCacheCheckedAt(r.Context())
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Letzte Firmware-Prüfung konnte nicht geladen werden")
+ return
+ }
+
+ writeJSON(w, http.StatusOK, map[string]any{
+ "canCheck": state.CanCheck,
+ "lastCheckedAt": lastCacheCheckedAt,
+ "lastManualCheckedAt": state.LastManualCheckedAt,
+ "nextAllowedAt": state.NextAllowedAt,
+ "remainingSeconds": state.RemainingSeconds,
+ })
+}
+
+func (s *Server) handleCheckCameraFirmware(w http.ResponseWriter, r *http.Request) {
+ user, ok := userFromContext(r.Context())
+ if !ok {
+ writeError(w, http.StatusUnauthorized, "Unauthorized")
+ return
+ }
+
+ state, err := s.getCameraFirmwareManualCheckState(r.Context())
+ if err != nil {
+ s.logAndWriteUserError(
+ w,
+ r,
+ user,
+ http.StatusInternalServerError,
+ "camera_firmware",
+ "load_manual_check_state",
+ "Firmware-Prüfstatus konnte nicht geladen werden",
+ err,
+ nil,
+ )
+ return
+ }
+
+ if !state.CanCheck {
+ writeJSON(w, http.StatusTooManyRequests, map[string]any{
+ "error": "Firmware kann nur einmal pro Stunde manuell geprüft werden",
+ "nextAllowedAt": state.NextAllowedAt,
+ "remainingSeconds": state.RemainingSeconds,
+ })
+ return
+ }
+
+ devices, err := s.listDevicesForFirmwareCheck(r.Context())
+ if err != nil {
+ s.logAndWriteUserError(
+ w,
+ r,
+ user,
+ http.StatusInternalServerError,
+ "camera_firmware",
+ "list_devices",
+ "Geräte konnten für die Firmware-Prüfung nicht geladen werden",
+ err,
+ nil,
+ )
+ return
+ }
+
+ checkedModels, failedModels, err := s.refreshCameraFirmwareForDevices(
+ r.Context(),
+ devices,
+ )
+ if err != nil {
+ s.logAndWriteUserError(
+ w,
+ r,
+ user,
+ http.StatusInternalServerError,
+ "camera_firmware",
+ "manual_check",
+ "Firmware-Prüfung konnte nicht ausgeführt werden",
+ err,
+ LogFields{
+ "checkedModels": checkedModels,
+ "failedModels": failedModels,
+ },
+ )
+ return
+ }
+
+ checkedAt := time.Now().UTC()
+
+ if err := s.saveCameraFirmwareManualCheckState(r.Context(), user.ID, checkedAt); err != nil {
+ s.logAndWriteUserError(
+ w,
+ r,
+ user,
+ http.StatusInternalServerError,
+ "camera_firmware",
+ "save_manual_check_state",
+ "Firmware-Prüfstatus konnte nicht gespeichert werden",
+ err,
+ nil,
+ )
+ return
+ }
+
+ s.logUserEvent(r.Context(), UserLogEntry{
+ User: &user,
+ Request: r,
+ Level: UserLogLevelInfo,
+ Category: "camera_firmware",
+ Action: "manual_check",
+ Message: "Firmware wurde manuell geprüft",
+ Details: LogFields{
+ "checkedModels": checkedModels,
+ "failedModels": failedModels,
+ },
+ })
+
+ writeJSON(w, http.StatusOK, map[string]any{
+ "ok": true,
+ "checkedModels": checkedModels,
+ "failedModels": failedModels,
+ "checkedAt": checkedAt,
+ "nextAllowedAt": checkedAt.Add(cameraFirmwareManualCheckCooldown),
+ "remainingSeconds": int64(cameraFirmwareManualCheckCooldown.Seconds()),
+ })
+}
+
+func (s *Server) getCameraFirmwareManualCheckState(
+ ctx context.Context,
+) (cameraFirmwareManualCheckState, error) {
+ state := cameraFirmwareManualCheckState{
+ CanCheck: true,
+ }
+
+ var lastCheckedAt sql.NullTime
+
+ err := s.db.QueryRow(
+ ctx,
+ `
+ SELECT last_checked_at
+ FROM camera_firmware_refresh_state
+ WHERE id = 1
+ LIMIT 1
+ `,
+ ).Scan(&lastCheckedAt)
+
+ if errors.Is(err, pgx.ErrNoRows) {
+ return state, nil
+ }
+
+ if err != nil {
+ return state, err
+ }
+
+ if !lastCheckedAt.Valid {
+ return state, nil
+ }
+
+ lastManualCheckedAt := lastCheckedAt.Time
+ nextAllowedAt := lastManualCheckedAt.Add(cameraFirmwareManualCheckCooldown)
+ now := time.Now()
+
+ state.LastManualCheckedAt = &lastManualCheckedAt
+ state.NextAllowedAt = &nextAllowedAt
+ state.CanCheck = !now.Before(nextAllowedAt)
+
+ if !state.CanCheck {
+ state.RemainingSeconds = int64(time.Until(nextAllowedAt).Seconds())
+ if state.RemainingSeconds < 1 {
+ state.RemainingSeconds = 1
+ }
+ }
+
+ return state, nil
+}
+
+func (s *Server) saveCameraFirmwareManualCheckState(
+ ctx context.Context,
+ userID string,
+ checkedAt time.Time,
+) error {
+ _, err := s.db.Exec(
+ ctx,
+ `
+ INSERT INTO camera_firmware_refresh_state (
+ id,
+ last_checked_at,
+ last_checked_by,
+ updated_at
+ )
+ VALUES (1, $1, $2, now())
+ ON CONFLICT (id)
+ DO UPDATE SET
+ last_checked_at = EXCLUDED.last_checked_at,
+ last_checked_by = EXCLUDED.last_checked_by,
+ updated_at = now()
+ `,
+ checkedAt,
+ userID,
+ )
+
+ return err
+}
+
+func (s *Server) getLastCameraFirmwareCacheCheckedAt(
+ ctx context.Context,
+) (*time.Time, error) {
+ var checkedAt sql.NullTime
+
+ err := s.db.QueryRow(
+ ctx,
+ `
+ SELECT MAX(checked_at)
+ FROM camera_firmware_cache
+ `,
+ ).Scan(&checkedAt)
+
+ if err != nil {
+ return nil, err
+ }
+
+ if !checkedAt.Valid {
+ return nil, nil
+ }
+
+ return &checkedAt.Time, nil
+}
+
+func (s *Server) listDevicesForFirmwareCheck(ctx context.Context) ([]Device, error) {
+ rows, err := s.db.Query(
+ ctx,
+ `
+ SELECT
+ id::TEXT,
+ manufacturer,
+ model,
+ COALESCE(firmware_version, '')
+ FROM devices
+ WHERE manufacturer <> ''
+ AND model <> ''
+ ORDER BY manufacturer ASC, model ASC
+ `,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ devices := []Device{}
+
+ for rows.Next() {
+ var device Device
+
+ if err := rows.Scan(
+ &device.ID,
+ &device.Manufacturer,
+ &device.Model,
+ &device.FirmwareVersion,
+ ); err != nil {
+ return nil, err
+ }
+
+ devices = append(devices, device)
+ }
+
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+
+ return devices, nil
+}
+
+func (s *Server) refreshCameraFirmwareForDevices(
+ ctx context.Context,
+ devices []Device,
+) (int, int, error) {
+ lookupKeys := map[firmwareLookupKey]bool{}
+
+ for _, device := range devices {
+ manufacturer := strings.TrimSpace(device.Manufacturer)
+ model := strings.TrimSpace(device.Model)
+
+ if manufacturer == "" || model == "" {
+ continue
+ }
+
+ provider := findCameraFirmwareProvider(manufacturer, model)
+ if provider == nil {
+ continue
+ }
+
+ lookupKeys[firmwareLookupKey{
+ Manufacturer: normalizeCameraFirmwareKey(manufacturer),
+ NormalizedModel: normalizeCameraFirmwareKey(model),
+ Model: model,
+ }] = true
+ }
+
+ checkedModels := 0
+ failedModels := 0
+
+ for lookupKey := range lookupKeys {
+ provider := findCameraFirmwareProvider(lookupKey.Manufacturer, lookupKey.Model)
+ if provider == nil {
+ continue
+ }
+
+ latest, err := provider.CheckLatestFirmware(
+ ctx,
+ lookupKey.Manufacturer,
+ lookupKey.Model,
+ )
+ if err != nil {
+ failedModels++
+
+ logError("Firmware-Version konnte nicht manuell geprüft werden", err, LogFields{
+ "manufacturer": lookupKey.Manufacturer,
+ "model": lookupKey.Model,
+ })
+
+ _ = s.recordCameraFirmwareCheckError(
+ ctx,
+ lookupKey.Manufacturer,
+ lookupKey.NormalizedModel,
+ err.Error(),
+ )
+
+ continue
+ }
+
+ if _, err := s.upsertCameraFirmwareCache(
+ ctx,
+ lookupKey.Manufacturer,
+ lookupKey.NormalizedModel,
+ latest,
+ ); err != nil {
+ return checkedModels, failedModels, err
+ }
+
+ checkedModels++
+ }
+
+ return checkedModels, failedModels, nil
+}
+
+func findCameraFirmwareProvider(manufacturer string, model string) cameraFirmwareProvider {
+ for _, provider := range cameraFirmwareProviders() {
+ if provider.Supports(manufacturer, model) {
+ return provider
+ }
+ }
+
+ return nil
+}
+
+func (s *Server) getCameraFirmwareCache(
+ ctx context.Context,
+ manufacturer string,
+ normalizedModel string,
+) (cameraFirmwareCacheEntry, bool, error) {
+ var entry cameraFirmwareCacheEntry
+ var supportBadgesJSON []byte
+
+ err := s.db.QueryRow(
+ ctx,
+ `
+ SELECT
+ manufacturer,
+ normalized_model,
+ latest_version,
+ support_url,
+ source,
+ check_error,
+ support_badges,
+ checked_at
+ FROM camera_firmware_cache
+ WHERE manufacturer = $1
+ AND normalized_model = $2
+ LIMIT 1
+ `,
+ manufacturer,
+ normalizedModel,
+ ).Scan(
+ &entry.Manufacturer,
+ &entry.NormalizedModel,
+ &entry.LatestVersion,
+ &entry.SupportURL,
+ &entry.Source,
+ &entry.CheckError,
+ &supportBadgesJSON,
+ &entry.CheckedAt,
+ )
+
+ if err == nil {
+ entry.SupportBadges = decodeDeviceSupportBadges(supportBadgesJSON)
+ return entry, true, nil
+ }
+
+ if err == pgx.ErrNoRows {
+ return entry, false, nil
+ }
+
+ return entry, false, err
+}
+
+func (s *Server) upsertCameraFirmwareCache(
+ ctx context.Context,
+ manufacturer string,
+ normalizedModel string,
+ latest cameraFirmwareLatest,
+) (cameraFirmwareCacheEntry, error) {
+ var entry cameraFirmwareCacheEntry
+ var supportBadgesJSON []byte
+
+ nextSupportBadgesJSON, err := json.Marshal(latest.SupportBadges)
+ if err != nil {
+ return entry, err
+ }
+
+ err = s.db.QueryRow(
+ ctx,
+ `
+ INSERT INTO camera_firmware_cache (
+ manufacturer,
+ normalized_model,
+ latest_version,
+ support_url,
+ source,
+ check_error,
+ support_badges,
+ checked_at,
+ updated_at
+ )
+ VALUES ($1, $2, $3, $4, $5, '', $6, now(), now())
+ ON CONFLICT (manufacturer, normalized_model)
+ DO UPDATE SET
+ latest_version = EXCLUDED.latest_version,
+ support_url = EXCLUDED.support_url,
+ source = EXCLUDED.source,
+ check_error = '',
+ support_badges = EXCLUDED.support_badges,
+ checked_at = now(),
+ updated_at = now()
+ RETURNING
+ manufacturer,
+ normalized_model,
+ latest_version,
+ support_url,
+ source,
+ check_error,
+ support_badges,
+ checked_at
+ `,
+ manufacturer,
+ normalizedModel,
+ latest.LatestVersion,
+ latest.SupportURL,
+ latest.Source,
+ nextSupportBadgesJSON,
+ ).Scan(
+ &entry.Manufacturer,
+ &entry.NormalizedModel,
+ &entry.LatestVersion,
+ &entry.SupportURL,
+ &entry.Source,
+ &entry.CheckError,
+ &supportBadgesJSON,
+ &entry.CheckedAt,
+ )
+ if err != nil {
+ return entry, err
+ }
+
+ entry.SupportBadges = decodeDeviceSupportBadges(supportBadgesJSON)
+
+ return entry, nil
+}
+
+func (s *Server) recordCameraFirmwareCheckError(
+ ctx context.Context,
+ manufacturer string,
+ normalizedModel string,
+ checkError string,
+) error {
+ _, err := s.db.Exec(
+ ctx,
+ `
+ INSERT INTO camera_firmware_cache (
+ manufacturer,
+ normalized_model,
+ check_error,
+ checked_at,
+ updated_at
+ )
+ VALUES ($1, $2, $3, now(), now())
+ ON CONFLICT (manufacturer, normalized_model)
+ DO UPDATE SET
+ check_error = EXCLUDED.check_error,
+ checked_at = now(),
+ updated_at = now()
+ `,
+ manufacturer,
+ normalizedModel,
+ checkError,
+ )
+
+ return err
+}
+
+func decodeDeviceSupportBadges(value []byte) []DeviceSupportBadge {
+ if len(value) == 0 {
+ return nil
+ }
+
+ var badges []DeviceSupportBadge
+
+ if err := json.Unmarshal(value, &badges); err != nil {
+ return nil
+ }
+
+ return badges
+}
+
+func normalizeCameraFirmwareKey(value string) string {
+ value = strings.TrimSpace(strings.ToLower(value))
+ value = strings.Join(strings.Fields(value), " ")
+
+ return value
+}
+
+type axisFirmwareProvider struct{}
+
+func (axisFirmwareProvider) Supports(manufacturer string, model string) bool {
+ manufacturer = strings.ToLower(strings.TrimSpace(manufacturer))
+ model = strings.ToLower(strings.TrimSpace(model))
+
+ return strings.Contains(manufacturer, "axis") ||
+ strings.HasPrefix(model, "axis ")
+}
+
+func (axisFirmwareProvider) CheckLatestFirmware(
+ ctx context.Context,
+ manufacturer string,
+ model string,
+) (cameraFirmwareLatest, error) {
+ supportURL := buildAxisSupportURL(model)
+
+ requestCtx, cancel := context.WithTimeout(ctx, 12*time.Second)
+ defer cancel()
+
+ request, err := http.NewRequestWithContext(
+ requestCtx,
+ http.MethodGet,
+ supportURL,
+ nil,
+ )
+ if err != nil {
+ return cameraFirmwareLatest{}, err
+ }
+
+ request.Header.Set("Accept", "text/html,application/xhtml+xml")
+ request.Header.Set("User-Agent", "TEG-Firmware-Checker/1.0")
+
+ response, err := http.DefaultClient.Do(request)
+ if err != nil {
+ return cameraFirmwareLatest{}, err
+ }
+ defer response.Body.Close()
+
+ body, err := io.ReadAll(io.LimitReader(response.Body, 3*1024*1024))
+ if err != nil {
+ return cameraFirmwareLatest{}, err
+ }
+
+ if response.StatusCode < 200 || response.StatusCode >= 300 {
+ return cameraFirmwareLatest{}, newAppErrorf(
+ "axis support page request failed",
+ LogFields{
+ "statusCode": response.StatusCode,
+ "supportUrl": supportURL,
+ },
+ "axis support page request failed: status=%d",
+ response.StatusCode,
+ )
+ }
+
+ latestVersion := extractAxisLatestFirmwareVersion(string(body))
+ supportBadges := extractAxisSupportBadges(string(body), supportURL)
+ if latestVersion == "" {
+ return cameraFirmwareLatest{}, newAppErrorf(
+ "axis firmware version not found",
+ LogFields{
+ "supportUrl": supportURL,
+ "model": model,
+ },
+ "axis firmware version not found for model %q",
+ model,
+ )
+ }
+
+ return cameraFirmwareLatest{
+ LatestVersion: latestVersion,
+ SupportURL: supportURL,
+ Source: "axis-support-page",
+ SupportBadges: supportBadges,
+ }, nil
+}
+
+var axisSupportSlugOverrides = map[string]string{
+ "f44": "axis-f44-main-unit",
+ "axis f44": "axis-f44-main-unit",
+ "axis f44 main unit": "axis-f44-main-unit",
+ "f44 main unit": "axis-f44-main-unit",
+}
+
+func buildAxisSupportURL(model string) string {
+ model = strings.TrimSpace(model)
+
+ modelKey := normalizeCameraFirmwareKey(model)
+ if slug, exists := axisSupportSlugOverrides[modelKey]; exists {
+ return "https://www.axis.com/products/" + slug + "/support"
+ }
+
+ if !strings.HasPrefix(strings.ToLower(model), "axis ") {
+ model = "AXIS " + model
+ }
+
+ modelKey = normalizeCameraFirmwareKey(model)
+ if slug, exists := axisSupportSlugOverrides[modelKey]; exists {
+ return "https://www.axis.com/products/" + slug + "/support"
+ }
+
+ slug := strings.ToLower(model)
+ var builder strings.Builder
+ previousDash := false
+
+ for _, r := range slug {
+ if unicode.IsLetter(r) || unicode.IsDigit(r) {
+ builder.WriteRune(r)
+ previousDash = false
+ continue
+ }
+
+ if !previousDash {
+ builder.WriteRune('-')
+ previousDash = true
+ }
+ }
+
+ slug = strings.Trim(builder.String(), "-")
+
+ return "https://www.axis.com/products/" + slug + "/support"
+}
+
+func extractAxisSupportBadges(body string, supportURL string) []DeviceSupportBadge {
+ body = html.UnescapeString(body)
+ body = regexp.MustCompile(`\s+`).ReplaceAllString(body, " ")
+
+ badges := []DeviceSupportBadge{}
+
+ hardwareSupportExpiredPattern := regexp.MustCompile(
+ `(?i)(Hardware support and RMA service expired on ([0-9]{4}-[0-9]{2}-[0-9]{2})\.?)`,
+ )
+
+ if matches := hardwareSupportExpiredPattern.FindStringSubmatch(body); len(matches) >= 3 {
+ message := strings.TrimSpace(matches[1])
+ if !strings.HasSuffix(message, ".") {
+ message += "."
+ }
+
+ badges = append(badges, DeviceSupportBadge{
+ Type: "hardware_support_expired",
+ Label: "Hardware/RMA abgelaufen",
+ Message: message,
+ Severity: "warning",
+ SupportURL: supportURL,
+ })
+ }
+
+ softwareSupportExpiredPattern := regexp.MustCompile(
+ `(?i)(Software support expired on ([0-9]{4}-[0-9]{2}-[0-9]{2})\.?)`,
+ )
+
+ if matches := softwareSupportExpiredPattern.FindStringSubmatch(body); len(matches) >= 3 {
+ message := strings.TrimSpace(matches[1])
+ if !strings.HasSuffix(message, ".") {
+ message += "."
+ }
+
+ badges = append(badges, DeviceSupportBadge{
+ Type: "software_support_expired",
+ Label: "Software-Support abgelaufen",
+ Message: message,
+ Severity: "danger",
+ SupportURL: supportURL,
+ })
+ }
+
+ axisOSSupportExpiredPattern := regexp.MustCompile(
+ `(?i)(AXIS OS support expired on ([0-9]{4}-[0-9]{2}-[0-9]{2})\.?)`,
+ )
+
+ if matches := axisOSSupportExpiredPattern.FindStringSubmatch(body); len(matches) >= 3 {
+ message := strings.TrimSpace(matches[1])
+ if !strings.HasSuffix(message, ".") {
+ message += "."
+ }
+
+ badges = append(badges, DeviceSupportBadge{
+ Type: "axis_os_support_expired",
+ Label: "AXIS OS abgelaufen",
+ Message: message,
+ Severity: "danger",
+ SupportURL: supportURL,
+ })
+ }
+
+ return badges
+}
+
+func extractAxisLatestFirmwareVersion(body string) string {
+ body = html.UnescapeString(body)
+
+ patterns := []*regexp.Regexp{
+ regexp.MustCompile(`(?i)Version\s+([0-9]+(?:\.[0-9]+){1,5})`),
+ regexp.MustCompile(`(?i)AXIS\s+OS[^0-9]{0,120}([0-9]+(?:\.[0-9]+){1,5})`),
+ regexp.MustCompile(`(?i)firmware[^0-9]{0,120}([0-9]+(?:\.[0-9]+){1,5})`),
+ }
+
+ for _, pattern := range patterns {
+ matches := pattern.FindStringSubmatch(body)
+ if len(matches) >= 2 {
+ return strings.TrimSpace(matches[1])
+ }
+ }
+
+ return ""
+}
+
+func isFirmwareVersionNewer(latestVersion string, currentVersion string) bool {
+ latestParts := parseFirmwareVersionParts(latestVersion)
+ currentParts := parseFirmwareVersionParts(currentVersion)
+
+ if len(latestParts) == 0 || len(currentParts) == 0 {
+ return false
+ }
+
+ maxLength := len(latestParts)
+ if len(currentParts) > maxLength {
+ maxLength = len(currentParts)
+ }
+
+ for index := 0; index < maxLength; index++ {
+ latestPart := 0
+ currentPart := 0
+
+ if index < len(latestParts) {
+ latestPart = latestParts[index]
+ }
+
+ if index < len(currentParts) {
+ currentPart = currentParts[index]
+ }
+
+ if latestPart > currentPart {
+ return true
+ }
+
+ if latestPart < currentPart {
+ return false
+ }
+ }
+
+ return false
+}
+
+func parseFirmwareVersionParts(value string) []int {
+ versionPattern := regexp.MustCompile(`[0-9]+(?:\.[0-9]+)+`)
+ version := versionPattern.FindString(value)
+ if version == "" {
+ return nil
+ }
+
+ rawParts := strings.Split(version, ".")
+ parts := make([]int, 0, len(rawParts))
+
+ for _, rawPart := range rawParts {
+ part, err := strconv.Atoi(rawPart)
+ if err != nil {
+ return nil
+ }
+
+ parts = append(parts, part)
+ }
+
+ return parts
+}
+
+func nullStringToString(value sql.NullString) string {
+ if !value.Valid {
+ return ""
+ }
+
+ return value.String
+}
diff --git a/backend/dashboard.go b/backend/dashboard.go
index f7283ab..1061479 100644
--- a/backend/dashboard.go
+++ b/backend/dashboard.go
@@ -45,6 +45,134 @@ type DashboardOperationChange struct {
CreatedAt time.Time `json:"createdAt"`
}
+type DashboardSettings struct {
+ UserID string `json:"userId"`
+ Settings json.RawMessage `json:"settings"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+}
+
+type DashboardSettingsRequest struct {
+ Settings json.RawMessage `json:"settings"`
+}
+
+func scanDashboardSettings(scan func(dest ...any) error) (DashboardSettings, error) {
+ var settings DashboardSettings
+ var settingsRaw []byte
+
+ err := scan(
+ &settings.UserID,
+ &settingsRaw,
+ &settings.CreatedAt,
+ &settings.UpdatedAt,
+ )
+
+ if len(settingsRaw) == 0 {
+ settingsRaw = []byte(`{}`)
+ }
+
+ settings.Settings = json.RawMessage(settingsRaw)
+
+ return settings, err
+}
+
+func (s *Server) handleGetDashboardSettings(w http.ResponseWriter, r *http.Request) {
+ user, ok := userFromContext(r.Context())
+ if !ok {
+ writeError(w, http.StatusUnauthorized, "Unauthorized")
+ return
+ }
+
+ settings, err := scanDashboardSettings(func(dest ...any) error {
+ return s.db.QueryRow(
+ r.Context(),
+ `
+ INSERT INTO dashboard_settings (
+ user_id,
+ settings
+ )
+ VALUES (
+ $1,
+ '{}'::JSONB
+ )
+ ON CONFLICT (user_id)
+ DO UPDATE SET
+ user_id = EXCLUDED.user_id
+ RETURNING
+ user_id::TEXT,
+ settings,
+ created_at,
+ updated_at
+ `,
+ user.ID,
+ ).Scan(dest...)
+ })
+
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Dashboard-Einstellungen konnten nicht geladen werden")
+ return
+ }
+
+ writeJSON(w, http.StatusOK, map[string]any{
+ "dashboardSettings": settings,
+ })
+}
+
+func (s *Server) handleUpdateDashboardSettings(w http.ResponseWriter, r *http.Request) {
+ user, ok := userFromContext(r.Context())
+ if !ok {
+ writeError(w, http.StatusUnauthorized, "Unauthorized")
+ return
+ }
+
+ var input DashboardSettingsRequest
+
+ if err := readJSON(r, &input); err != nil {
+ writeError(w, http.StatusBadRequest, "Invalid JSON")
+ return
+ }
+
+ if len(input.Settings) == 0 || !json.Valid(input.Settings) {
+ input.Settings = json.RawMessage(`{}`)
+ }
+
+ settings, err := scanDashboardSettings(func(dest ...any) error {
+ return s.db.QueryRow(
+ r.Context(),
+ `
+ INSERT INTO dashboard_settings (
+ user_id,
+ settings
+ )
+ VALUES (
+ $1,
+ $2::JSONB
+ )
+ ON CONFLICT (user_id)
+ DO UPDATE SET
+ settings = EXCLUDED.settings,
+ updated_at = now()
+ RETURNING
+ user_id::TEXT,
+ settings,
+ created_at,
+ updated_at
+ `,
+ user.ID,
+ string(input.Settings),
+ ).Scan(dest...)
+ })
+
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Dashboard-Einstellungen konnten nicht gespeichert werden")
+ return
+ }
+
+ writeJSON(w, http.StatusOK, map[string]any{
+ "dashboardSettings": settings,
+ })
+}
+
func normalizeDashboardWidgetInput(input *DashboardWidgetRequest) {
input.Type = strings.TrimSpace(input.Type)
input.Title = strings.TrimSpace(input.Title)
diff --git a/backend/device_events.go b/backend/device_events.go
new file mode 100644
index 0000000..99049bd
--- /dev/null
+++ b/backend/device_events.go
@@ -0,0 +1,118 @@
+// backend/device_events.go
+
+package main
+
+import (
+ "context"
+ "encoding/json"
+)
+
+func (s *Server) publishDeviceChangeEvent(
+ ctx context.Context,
+ actorUserID string,
+ deviceID string,
+ notificationType string,
+ title string,
+ message string,
+ data map[string]any,
+) error {
+ if data == nil {
+ data = map[string]any{}
+ }
+
+ data["deviceId"] = deviceID
+ data["actorUserId"] = actorUserID
+
+ dataJSON, err := json.Marshal(data)
+ if err != nil {
+ return err
+ }
+
+ rows, err := s.db.Query(
+ ctx,
+ `
+ SELECT id::TEXT
+ FROM users
+ WHERE 'admin' = ANY(rights)
+ OR 'devices:read' = ANY(rights)
+ OR 'devices:write' = ANY(rights)
+ `,
+ )
+ if err != nil {
+ return err
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var userID string
+
+ if err := rows.Scan(&userID); err != nil {
+ return err
+ }
+
+ var notification Notification
+
+ err = s.db.QueryRow(
+ ctx,
+ `
+ INSERT INTO notifications (
+ user_id,
+ type,
+ title,
+ message,
+ visible,
+ entity_type,
+ entity_id,
+ data
+ )
+ VALUES (
+ $1,
+ $2,
+ $3,
+ $4,
+ false,
+ 'device',
+ $5,
+ $6
+ )
+ RETURNING
+ id::TEXT,
+ user_id::TEXT,
+ type,
+ title,
+ message,
+ entity_type,
+ COALESCE(entity_id::TEXT, ''),
+ data,
+ visible,
+ read_at,
+ created_at
+ `,
+ userID,
+ notificationType,
+ title,
+ message,
+ deviceID,
+ dataJSON,
+ ).Scan(
+ ¬ification.ID,
+ ¬ification.UserID,
+ ¬ification.Type,
+ ¬ification.Title,
+ ¬ification.Message,
+ ¬ification.EntityType,
+ ¬ification.EntityID,
+ ¬ification.Data,
+ ¬ification.Visible,
+ ¬ification.ReadAt,
+ ¬ification.CreatedAt,
+ )
+ if err != nil {
+ return err
+ }
+
+ notificationsBroker.publish(notification)
+ }
+
+ return rows.Err()
+}
diff --git a/backend/devices.go b/backend/devices.go
index af18c48..5e2aded 100644
--- a/backend/devices.go
+++ b/backend/devices.go
@@ -14,19 +14,45 @@ import (
)
type Device struct {
- ID string `json:"id"`
- InventoryNumber string `json:"inventoryNumber"`
- Manufacturer string `json:"manufacturer"`
- Model string `json:"model"`
- SerialNumber string `json:"serialNumber"`
- MacAddress string `json:"macAddress"`
- Location string `json:"location"`
- LoanStatus string `json:"loanStatus"`
- IsBaoDevice bool `json:"isBaoDevice"`
- Comment string `json:"comment"`
- RelatedDevices []DeviceShort `json:"relatedDevices"`
- CreatedAt time.Time `json:"createdAt"`
- UpdatedAt time.Time `json:"updatedAt"`
+ ID string `json:"id"`
+ InventoryNumber string `json:"inventoryNumber"`
+ Manufacturer string `json:"manufacturer"`
+ Model string `json:"model"`
+ SerialNumber string `json:"serialNumber"`
+ MacAddress string `json:"macAddress"`
+ IPAddress string `json:"ipAddress"`
+ Location string `json:"location"`
+ LoanStatus string `json:"loanStatus"`
+ IsBaoDevice bool `json:"isBaoDevice"`
+ Comment string `json:"comment"`
+ FirmwareVersion string `json:"firmwareVersion"`
+ FirmwareUpdate *DeviceFirmwareUpdate `json:"firmwareUpdate,omitempty"`
+ SupportBadges []DeviceSupportBadge `json:"supportBadges,omitempty"`
+ MilestoneRecordingServerID string `json:"milestoneRecordingServerId"`
+ MilestoneHardwareID string `json:"milestoneHardwareId"`
+ MilestoneDisplayName string `json:"milestoneDisplayName"`
+ MilestoneEnabled bool `json:"milestoneEnabled"`
+ MilestoneSyncedAt *time.Time `json:"milestoneSyncedAt,omitempty"`
+ RelatedDevices []DeviceShort `json:"relatedDevices"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+}
+
+type DeviceFirmwareUpdate struct {
+ Available bool `json:"available"`
+ CurrentVersion string `json:"currentVersion"`
+ LatestVersion string `json:"latestVersion"`
+ SupportURL string `json:"supportUrl"`
+ Source string `json:"source"`
+ CheckedAt *time.Time `json:"checkedAt,omitempty"`
+}
+
+type DeviceSupportBadge struct {
+ Type string `json:"type"`
+ Label string `json:"label"`
+ Message string `json:"message"`
+ Severity string `json:"severity"`
+ SupportURL string `json:"supportUrl"`
}
type DeviceShort struct {
@@ -37,16 +63,19 @@ type DeviceShort struct {
}
type CreateDeviceRequest struct {
- InventoryNumber string `json:"inventoryNumber"`
- Manufacturer string `json:"manufacturer"`
- Model string `json:"model"`
- SerialNumber string `json:"serialNumber"`
- MacAddress string `json:"macAddress"`
- Location string `json:"location"`
- LoanStatus string `json:"loanStatus"`
- IsBaoDevice bool `json:"isBaoDevice"`
- Comment string `json:"comment"`
- RelatedDeviceIDs []string `json:"relatedDeviceIds"`
+ InventoryNumber string `json:"inventoryNumber"`
+ Manufacturer string `json:"manufacturer"`
+ Model string `json:"model"`
+ SerialNumber string `json:"serialNumber"`
+ MacAddress string `json:"macAddress"`
+ IPAddress string `json:"ipAddress"`
+ FirmwareVersion string `json:"firmwareVersion"`
+ MilestoneDisplayName string `json:"milestoneDisplayName"`
+ Location string `json:"location"`
+ LoanStatus string `json:"loanStatus"`
+ IsBaoDevice bool `json:"isBaoDevice"`
+ Comment string `json:"comment"`
+ RelatedDeviceIDs []string `json:"relatedDeviceIds"`
}
type UpdateDeviceRequest = CreateDeviceRequest
@@ -75,10 +104,17 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
model,
serial_number,
mac_address,
+ COALESCE(ip_address, ''),
location,
loan_status,
is_bao_device,
comment,
+ COALESCE(firmware_version, ''),
+ COALESCE(milestone_recording_server_id, ''),
+ COALESCE(milestone_hardware_id, ''),
+ COALESCE(milestone_display_name, ''),
+ milestone_enabled,
+ milestone_synced_at,
created_at,
updated_at
FROM devices
@@ -105,10 +141,17 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
&device.Model,
&device.SerialNumber,
&device.MacAddress,
+ &device.IPAddress,
&device.Location,
&device.LoanStatus,
&device.IsBaoDevice,
&device.Comment,
+ &device.FirmwareVersion,
+ &device.MilestoneRecordingServerID,
+ &device.MilestoneHardwareID,
+ &device.MilestoneDisplayName,
+ &device.MilestoneEnabled,
+ &device.MilestoneSyncedAt,
&device.CreatedAt,
&device.UpdatedAt,
); err != nil {
@@ -175,6 +218,8 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
return
}
+ s.enrichDevicesWithFirmwareUpdates(r.Context(), devices)
+
writeJSON(w, http.StatusOK, map[string]any{
"devices": devices,
})
@@ -224,12 +269,14 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
model,
serial_number,
mac_address,
+ ip_address,
location,
loan_status,
is_bao_device,
- comment
+ comment,
+ firmware_version
)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING
id,
inventory_number,
@@ -237,10 +284,17 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
model,
serial_number,
mac_address,
+ COALESCE(ip_address, ''),
location,
loan_status,
is_bao_device,
comment,
+ COALESCE(firmware_version, ''),
+ COALESCE(milestone_recording_server_id, ''),
+ COALESCE(milestone_hardware_id, ''),
+ COALESCE(milestone_display_name, ''),
+ COALESCE(milestone_enabled, true),
+ milestone_synced_at,
created_at,
updated_at
`,
@@ -249,10 +303,12 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
input.Model,
input.SerialNumber,
input.MacAddress,
+ input.IPAddress,
input.Location,
input.LoanStatus,
input.IsBaoDevice,
input.Comment,
+ input.FirmwareVersion,
).Scan(
&device.ID,
&device.InventoryNumber,
@@ -260,10 +316,17 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
&device.Model,
&device.SerialNumber,
&device.MacAddress,
+ &device.IPAddress,
&device.Location,
&device.LoanStatus,
&device.IsBaoDevice,
&device.Comment,
+ &device.FirmwareVersion,
+ &device.MilestoneRecordingServerID,
+ &device.MilestoneHardwareID,
+ &device.MilestoneDisplayName,
+ &device.MilestoneEnabled,
+ &device.MilestoneSyncedAt,
&device.CreatedAt,
&device.UpdatedAt,
)
@@ -308,6 +371,28 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
device.RelatedDevices = []DeviceShort{}
+ if err := s.publishDeviceChangeEvent(
+ r.Context(),
+ user.ID,
+ device.ID,
+ "device.created",
+ "Gerät erstellt",
+ formatText(
+ "Gerät %s wurde erstellt.",
+ device.InventoryNumber,
+ ),
+ LogFields{
+ "inventoryNumber": device.InventoryNumber,
+ "manufacturer": device.Manufacturer,
+ "model": device.Model,
+ },
+ ); err != nil {
+ logError("Device-SSE-Event konnte nicht gesendet werden", err, LogFields{
+ "deviceId": device.ID,
+ "type": "device.created",
+ })
+ }
+
writeJSON(w, http.StatusCreated, map[string]any{
"device": device,
})
@@ -376,10 +461,13 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
model = $4,
serial_number = $5,
mac_address = $6,
- location = $7,
- loan_status = $8,
- is_bao_device = $9,
- comment = $10,
+ ip_address = $7,
+ location = $8,
+ loan_status = $9,
+ is_bao_device = $10,
+ comment = $11,
+ firmware_version = $12,
+ milestone_display_name = $13,
updated_at = now()
WHERE id = $1
RETURNING
@@ -389,10 +477,17 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
model,
serial_number,
mac_address,
+ COALESCE(ip_address, ''),
location,
loan_status,
is_bao_device,
comment,
+ COALESCE(firmware_version, ''),
+ COALESCE(milestone_recording_server_id, ''),
+ COALESCE(milestone_hardware_id, ''),
+ COALESCE(milestone_display_name, ''),
+ COALESCE(milestone_enabled, true),
+ milestone_synced_at,
created_at,
updated_at
`,
@@ -402,10 +497,13 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
input.Model,
input.SerialNumber,
input.MacAddress,
+ input.IPAddress,
input.Location,
input.LoanStatus,
input.IsBaoDevice,
input.Comment,
+ input.FirmwareVersion,
+ input.MilestoneDisplayName,
).Scan(
&device.ID,
&device.InventoryNumber,
@@ -413,10 +511,17 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
&device.Model,
&device.SerialNumber,
&device.MacAddress,
+ &device.IPAddress,
&device.Location,
&device.LoanStatus,
&device.IsBaoDevice,
&device.Comment,
+ &device.FirmwareVersion,
+ &device.MilestoneRecordingServerID,
+ &device.MilestoneHardwareID,
+ &device.MilestoneDisplayName,
+ &device.MilestoneEnabled,
+ &device.MilestoneSyncedAt,
&device.CreatedAt,
&device.UpdatedAt,
)
@@ -468,6 +573,29 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
device.RelatedDevices = []DeviceShort{}
+ if err := s.publishDeviceChangeEvent(
+ r.Context(),
+ user.ID,
+ device.ID,
+ "device.updated",
+ "Gerät geändert",
+ formatText(
+ "Gerät %s wurde geändert.",
+ device.InventoryNumber,
+ ),
+ LogFields{
+ "inventoryNumber": device.InventoryNumber,
+ "manufacturer": device.Manufacturer,
+ "model": device.Model,
+ "changes": changes,
+ },
+ ); err != nil {
+ logError("Device-SSE-Event konnte nicht gesendet werden", err, LogFields{
+ "deviceId": device.ID,
+ "type": "device.updated",
+ })
+ }
+
writeJSON(w, http.StatusOK, map[string]any{
"device": device,
})
@@ -505,6 +633,9 @@ func normalizeDeviceInput(input *CreateDeviceRequest) {
input.Model = strings.TrimSpace(input.Model)
input.SerialNumber = strings.TrimSpace(input.SerialNumber)
input.MacAddress = strings.TrimSpace(input.MacAddress)
+ input.IPAddress = strings.TrimSpace(input.IPAddress)
+ input.FirmwareVersion = strings.TrimSpace(input.FirmwareVersion)
+ input.MilestoneDisplayName = strings.TrimSpace(input.MilestoneDisplayName)
input.Location = strings.TrimSpace(input.Location)
input.LoanStatus = strings.TrimSpace(input.LoanStatus)
input.Comment = strings.TrimSpace(input.Comment)
diff --git a/backend/feedback.go b/backend/feedback.go
new file mode 100644
index 0000000..474ca0c
--- /dev/null
+++ b/backend/feedback.go
@@ -0,0 +1,445 @@
+// backend/feedback.go
+
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+)
+
+type CreateFeedbackRequest struct {
+ Message string `json:"message"`
+}
+
+type FeedbackResponse struct {
+ ID string `json:"id"`
+ UserID string `json:"userId"`
+ UserName string `json:"userName"`
+ UserEmail string `json:"userEmail"`
+ Message string `json:"message"`
+ Log json.RawMessage `json:"log"`
+ CreatedAt time.Time `json:"createdAt"`
+}
+
+func (s *Server) handleCreateFeedback(w http.ResponseWriter, r *http.Request) {
+ user, ok := userFromContext(r.Context())
+ if !ok {
+ writeError(w, http.StatusUnauthorized, "Unauthorized")
+ return
+ }
+
+ var input CreateFeedbackRequest
+
+ if err := readJSON(r, &input); err != nil {
+ s.logUserEvent(r.Context(), UserLogEntry{
+ User: &user,
+ Request: r,
+ Level: UserLogLevelWarning,
+ Category: "feedback",
+ Action: "read_request",
+ Message: "Feedback-Request konnte nicht gelesen werden",
+ Error: err,
+ })
+
+ writeError(w, http.StatusBadRequest, "Invalid JSON")
+ return
+ }
+
+ input.Message = strings.TrimSpace(input.Message)
+
+ if input.Message == "" {
+ writeError(w, http.StatusBadRequest, "Feedback darf nicht leer sein")
+ return
+ }
+
+ if len([]rune(input.Message)) > 5000 {
+ writeError(w, http.StatusBadRequest, "Feedback darf maximal 5000 Zeichen lang sein")
+ return
+ }
+
+ feedbackLog := map[string]any{
+ "user": buildUserLogSnapshot(user),
+ "request": mergeLogFields(
+ LogFields(buildRequestLogSnapshot(r)),
+ LogFields{
+ "contentType": r.Header.Get("Content-Type"),
+ },
+ ),
+ "server": map[string]any{
+ "createdAt": time.Now().UTC().Format(time.RFC3339Nano),
+ },
+ }
+
+ logJSON, err := json.Marshal(feedbackLog)
+ if err != nil {
+ s.logAndWriteUserError(
+ w,
+ r,
+ user,
+ http.StatusInternalServerError,
+ "feedback",
+ "build_feedback_log",
+ "Feedback-Log konnte nicht erstellt werden",
+ err,
+ LogFields{
+ "messageLength": len([]rune(input.Message)),
+ },
+ )
+ return
+ }
+
+ var feedback FeedbackResponse
+
+ err = s.db.QueryRow(
+ r.Context(),
+ `
+ INSERT INTO feedback (
+ user_id,
+ message,
+ log
+ )
+ VALUES ($1, $2, $3)
+ RETURNING
+ id::TEXT,
+ user_id::TEXT,
+ $4::TEXT,
+ $5::TEXT,
+ message,
+ log,
+ created_at
+ `,
+ user.ID,
+ input.Message,
+ logJSON,
+ user.DisplayName,
+ user.Email,
+ ).Scan(
+ &feedback.ID,
+ &feedback.UserID,
+ &feedback.UserName,
+ &feedback.UserEmail,
+ &feedback.Message,
+ &feedback.Log,
+ &feedback.CreatedAt,
+ )
+ if err != nil {
+ s.logAndWriteUserError(
+ w,
+ r,
+ user,
+ http.StatusInternalServerError,
+ "feedback",
+ "insert_feedback",
+ "Feedback konnte nicht gespeichert werden",
+ err,
+ LogFields{
+ "messageLength": len([]rune(input.Message)),
+ },
+ )
+ return
+ }
+
+ s.logUserEvent(r.Context(), UserLogEntry{
+ User: &user,
+ Request: r,
+ Level: UserLogLevelInfo,
+ Category: "feedback",
+ Action: "create_feedback",
+ Message: "Feedback wurde gespeichert",
+ Details: LogFields{
+ "feedbackId": feedback.ID,
+ "messageLength": len([]rune(input.Message)),
+ },
+ })
+
+ _ = s.notifyAdministratorsAboutFeedback(
+ r.Context(),
+ "feedback.created",
+ "Neues Feedback",
+ formatText("Neues Feedback von %s.", displayFeedbackUserName(feedback)),
+ feedback.ID,
+ true,
+ map[string]any{
+ "feedbackId": feedback.ID,
+ "userId": feedback.UserID,
+ "userName": feedback.UserName,
+ "userEmail": feedback.UserEmail,
+ },
+ )
+
+ writeJSON(w, http.StatusCreated, map[string]any{
+ "feedback": feedback,
+ })
+}
+
+func (s *Server) handleListFeedback(w http.ResponseWriter, r *http.Request) {
+ rows, err := s.db.Query(
+ r.Context(),
+ `
+ SELECT
+ f.id::TEXT,
+ f.user_id::TEXT,
+ COALESCE(NULLIF(u.display_name, ''), NULLIF(u.username, ''), u.email, 'Unbekannt') AS user_name,
+ COALESCE(u.email, '') AS user_email,
+ f.message,
+ f.log,
+ f.created_at
+ FROM feedback f
+ LEFT JOIN users u ON u.id = f.user_id
+ ORDER BY f.created_at DESC
+ LIMIT 200
+ `,
+ )
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Feedback konnte nicht geladen werden")
+ return
+ }
+ defer rows.Close()
+
+ feedbackItems := make([]FeedbackResponse, 0)
+
+ for rows.Next() {
+ var feedback FeedbackResponse
+
+ if err := rows.Scan(
+ &feedback.ID,
+ &feedback.UserID,
+ &feedback.UserName,
+ &feedback.UserEmail,
+ &feedback.Message,
+ &feedback.Log,
+ &feedback.CreatedAt,
+ ); err != nil {
+ writeError(w, http.StatusInternalServerError, "Feedback konnte nicht gelesen werden")
+ return
+ }
+
+ feedbackItems = append(feedbackItems, feedback)
+ }
+
+ if err := rows.Err(); err != nil {
+ writeError(w, http.StatusInternalServerError, "Feedback konnte nicht gelesen werden")
+ return
+ }
+
+ writeJSON(w, http.StatusOK, map[string]any{
+ "feedback": feedbackItems,
+ })
+}
+
+func (s *Server) handleDeleteFeedback(w http.ResponseWriter, r *http.Request) {
+ user, ok := userFromContext(r.Context())
+ if !ok {
+ writeError(w, http.StatusUnauthorized, "Unauthorized")
+ return
+ }
+
+ feedbackID := strings.TrimSpace(r.PathValue("id"))
+ if feedbackID == "" {
+ writeError(w, http.StatusBadRequest, "Feedback-ID fehlt")
+ return
+ }
+
+ var deleted FeedbackResponse
+
+ err := s.db.QueryRow(
+ r.Context(),
+ `
+ DELETE FROM feedback f
+ USING users u
+ WHERE f.id = $1
+ AND u.id = f.user_id
+ RETURNING
+ f.id::TEXT,
+ f.user_id::TEXT,
+ COALESCE(NULLIF(u.display_name, ''), NULLIF(u.username, ''), u.email, 'Unbekannt') AS user_name,
+ COALESCE(u.email, '') AS user_email,
+ f.message,
+ f.log,
+ f.created_at
+ `,
+ feedbackID,
+ ).Scan(
+ &deleted.ID,
+ &deleted.UserID,
+ &deleted.UserName,
+ &deleted.UserEmail,
+ &deleted.Message,
+ &deleted.Log,
+ &deleted.CreatedAt,
+ )
+ if errors.Is(err, pgx.ErrNoRows) {
+ writeError(w, http.StatusNotFound, "Feedback wurde nicht gefunden")
+ return
+ }
+ if err != nil {
+ s.logAndWriteUserError(
+ w,
+ r,
+ user,
+ http.StatusInternalServerError,
+ "feedback",
+ "delete_feedback",
+ "Feedback konnte nicht gelöscht werden",
+ err,
+ LogFields{
+ "feedbackId": feedbackID,
+ },
+ )
+ return
+ }
+
+ s.logUserEvent(r.Context(), UserLogEntry{
+ User: &user,
+ Request: r,
+ Level: UserLogLevelInfo,
+ Category: "feedback",
+ Action: "delete_feedback",
+ Message: "Feedback wurde gelöscht",
+ Details: LogFields{
+ "feedbackId": deleted.ID,
+ "userId": deleted.UserID,
+ },
+ })
+
+ _ = s.notifyAdministratorsAboutFeedback(
+ r.Context(),
+ "feedback.deleted",
+ "Feedback gelöscht",
+ formatText("Feedback von %s wurde gelöscht.", displayFeedbackUserName(deleted)),
+ deleted.ID,
+ false,
+ map[string]any{
+ "feedbackId": deleted.ID,
+ "userId": deleted.UserID,
+ "userName": deleted.UserName,
+ "userEmail": deleted.UserEmail,
+ },
+ )
+
+ writeJSON(w, http.StatusOK, map[string]any{
+ "ok": true,
+ })
+}
+
+func displayFeedbackUserName(feedback FeedbackResponse) string {
+ if strings.TrimSpace(feedback.UserName) != "" {
+ return feedback.UserName
+ }
+
+ if strings.TrimSpace(feedback.UserEmail) != "" {
+ return feedback.UserEmail
+ }
+
+ return "Unbekannt"
+}
+
+func (s *Server) notifyAdministratorsAboutFeedback(
+ ctx context.Context,
+ notificationType string,
+ title string,
+ message string,
+ feedbackID string,
+ visible bool,
+ data map[string]any,
+) error {
+ adminRows, err := s.db.Query(
+ ctx,
+ `
+ SELECT id::TEXT
+ FROM users
+ WHERE 'admin' = ANY(rights)
+ OR 'administration:read' = ANY(rights)
+ `,
+ )
+ if err != nil {
+ return err
+ }
+ defer adminRows.Close()
+
+ dataJSON, err := json.Marshal(data)
+ if err != nil {
+ return err
+ }
+
+ for adminRows.Next() {
+ var adminUserID string
+
+ if err := adminRows.Scan(&adminUserID); err != nil {
+ return err
+ }
+
+ var notification Notification
+
+ err = s.db.QueryRow(
+ ctx,
+ `
+ INSERT INTO notifications (
+ user_id,
+ type,
+ title,
+ message,
+ visible,
+ entity_type,
+ entity_id,
+ data
+ )
+ VALUES (
+ $1,
+ $2,
+ $3,
+ $4,
+ true,
+ 'feedback',
+ $5,
+ $6,
+ $7
+ )
+ RETURNING
+ id::TEXT,
+ user_id::TEXT,
+ type,
+ title,
+ message,
+ entity_type,
+ COALESCE(entity_id::TEXT, ''),
+ data,
+ visible,
+ read_at,
+ created_at
+ `,
+ adminUserID,
+ notificationType,
+ title,
+ message,
+ visible,
+ feedbackID,
+ dataJSON,
+ ).Scan(
+ ¬ification.ID,
+ ¬ification.UserID,
+ ¬ification.Type,
+ ¬ification.Title,
+ ¬ification.Message,
+ ¬ification.EntityType,
+ ¬ification.EntityID,
+ ¬ification.Data,
+ ¬ification.Visible,
+ ¬ification.ReadAt,
+ ¬ification.CreatedAt,
+ )
+ if err != nil {
+ return err
+ }
+
+ notificationsBroker.publish(notification)
+ }
+
+ return adminRows.Err()
+}
diff --git a/backend/log.go b/backend/log.go
new file mode 100644
index 0000000..172f20e
--- /dev/null
+++ b/backend/log.go
@@ -0,0 +1,158 @@
+// backend/log.go
+
+package main
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log"
+ "os"
+ "time"
+)
+
+type LogLevel string
+
+const (
+ LogLevelInfo LogLevel = "info"
+ LogLevelWarning LogLevel = "warning"
+ LogLevelError LogLevel = "error"
+)
+
+type LogFields map[string]any
+
+type AppError struct {
+ Message string
+ Err error
+ Fields LogFields
+}
+
+var appLogger = log.New(os.Stdout, "", 0)
+
+func (e *AppError) Error() string {
+ if e == nil {
+ return ""
+ }
+
+ if e.Err == nil {
+ return e.Message
+ }
+
+ if e.Message == "" {
+ return e.Err.Error()
+ }
+
+ return e.Message + ": " + e.Err.Error()
+}
+
+func (e *AppError) Unwrap() error {
+ if e == nil {
+ return nil
+ }
+
+ return e.Err
+}
+
+func newErrorf(format string, args ...any) error {
+ return fmt.Errorf(format, args...)
+}
+
+func newAppError(message string, err error, fields LogFields) error {
+ return &AppError{
+ Message: message,
+ Err: err,
+ Fields: fields,
+ }
+}
+
+func newAppErrorf(message string, fields LogFields, format string, args ...any) error {
+ return newAppError(
+ message,
+ fmt.Errorf(format, args...),
+ fields,
+ )
+}
+
+func formatText(format string, args ...any) string {
+ return fmt.Sprintf(format, args...)
+}
+
+func logInfo(message string, fields LogFields) {
+ writeAppLog(LogLevelInfo, message, nil, fields)
+}
+
+func logInfof(format string, args ...any) {
+ writeAppLog(LogLevelInfo, fmt.Sprintf(format, args...), nil, nil)
+}
+
+func logWarning(message string, fields LogFields) {
+ writeAppLog(LogLevelWarning, message, nil, fields)
+}
+
+func logWarningf(format string, args ...any) {
+ writeAppLog(LogLevelWarning, fmt.Sprintf(format, args...), nil, nil)
+}
+
+func logError(message string, err error, fields LogFields) {
+ writeAppLog(LogLevelError, message, err, fields)
+}
+
+func logErrorf(err error, format string, args ...any) {
+ writeAppLog(LogLevelError, fmt.Sprintf(format, args...), err, nil)
+}
+
+func appErrorFields(err error) LogFields {
+ var appErr *AppError
+
+ if errors.As(err, &appErr) && appErr.Fields != nil {
+ return appErr.Fields
+ }
+
+ return nil
+}
+
+func mergeLogFields(base LogFields, extra LogFields) LogFields {
+ if len(base) == 0 && len(extra) == 0 {
+ return nil
+ }
+
+ merged := LogFields{}
+
+ for key, value := range base {
+ merged[key] = value
+ }
+
+ for key, value := range extra {
+ merged[key] = value
+ }
+
+ return merged
+}
+
+func writeAppLog(level LogLevel, message string, err error, fields LogFields) {
+ payload := map[string]any{
+ "timestamp": time.Now().UTC().Format(time.RFC3339Nano),
+ "level": level,
+ "message": message,
+ }
+
+ if err != nil {
+ payload["error"] = err.Error()
+ }
+
+ if len(fields) > 0 {
+ payload["fields"] = fields
+ }
+
+ data, marshalErr := json.Marshal(payload)
+ if marshalErr != nil {
+ appLogger.Printf(
+ `{"timestamp":"%s","level":"error","message":"could not marshal log payload","error":%q}`,
+ time.Now().UTC().Format(time.RFC3339Nano),
+ marshalErr.Error(),
+ )
+ return
+ }
+
+ appLogger.Println(string(data))
+}
diff --git a/backend/milestone_devices.go b/backend/milestone_devices.go
new file mode 100644
index 0000000..ffa12ab
--- /dev/null
+++ b/backend/milestone_devices.go
@@ -0,0 +1,354 @@
+// backend\milestone_devices.go
+
+package main
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "unicode"
+
+ "github.com/jackc/pgx/v5"
+)
+
+func syncMilestoneHardwareDevices(
+ ctx context.Context,
+ tx pgx.Tx,
+ hardwareDevices []milestoneHardwareDevice,
+) (int, error) {
+ syncedCount := 0
+
+ for _, hardwareDevice := range hardwareDevices {
+ hardwareDevice.HardwareID = strings.TrimSpace(hardwareDevice.HardwareID)
+ hardwareDevice.RecordingServerID = strings.TrimSpace(hardwareDevice.RecordingServerID)
+ hardwareDevice.DisplayName = strings.TrimSpace(hardwareDevice.DisplayName)
+ hardwareDevice.DeviceName = strings.TrimSpace(hardwareDevice.DeviceName)
+ hardwareDevice.Manufacturer = strings.TrimSpace(hardwareDevice.Manufacturer)
+ hardwareDevice.DeviceModelName = strings.TrimSpace(hardwareDevice.DeviceModelName)
+ hardwareDevice.SerialNumber = strings.TrimSpace(hardwareDevice.SerialNumber)
+ hardwareDevice.MacAddress = strings.TrimSpace(hardwareDevice.MacAddress)
+ hardwareDevice.IPAddress = strings.TrimSpace(hardwareDevice.IPAddress)
+ hardwareDevice.FirmwareVersion = strings.TrimSpace(hardwareDevice.FirmwareVersion)
+
+ if hardwareDevice.HardwareID == "" {
+ continue
+ }
+
+ if hardwareDevice.Manufacturer != "" {
+ if err := ensureOptionalDeviceLookupValue(
+ ctx,
+ tx,
+ "device_manufacturers",
+ hardwareDevice.Manufacturer,
+ ); err != nil {
+ return syncedCount, err
+ }
+ }
+
+ synced, err := createOrUpdateMilestoneHardwareDevice(ctx, tx, hardwareDevice)
+ if err != nil {
+ return syncedCount, err
+ }
+
+ if synced {
+ syncedCount++
+ }
+ }
+
+ return syncedCount, nil
+}
+
+func createOrUpdateMilestoneHardwareDevice(
+ ctx context.Context,
+ tx pgx.Tx,
+ hardwareDevice milestoneHardwareDevice,
+) (bool, error) {
+ inventoryNumber, err := buildMilestoneInventoryNumber(ctx, tx, hardwareDevice)
+ if err != nil {
+ return false, err
+ }
+
+ commandTag, err := tx.Exec(
+ ctx,
+ `
+ INSERT INTO devices (
+ inventory_number,
+ manufacturer,
+ model,
+ serial_number,
+ mac_address,
+ ip_address,
+ location,
+ loan_status,
+ is_bao_device,
+ comment,
+ firmware_version,
+ milestone_recording_server_id,
+ milestone_hardware_id,
+ milestone_display_name,
+ milestone_enabled,
+ milestone_synced_at
+ )
+ VALUES (
+ $1,
+ $2,
+ $3,
+ $4,
+ $5,
+ $6,
+ '',
+ 'Verfügbar',
+ false,
+ $7,
+ $8,
+ $9,
+ $10,
+ $11,
+ $12,
+ now()
+ )
+ ON CONFLICT (milestone_hardware_id)
+ WHERE milestone_hardware_id <> ''
+ DO UPDATE SET
+ inventory_number = EXCLUDED.inventory_number,
+ manufacturer = EXCLUDED.manufacturer,
+ model = EXCLUDED.model,
+ serial_number = EXCLUDED.serial_number,
+ mac_address = EXCLUDED.mac_address,
+ ip_address = EXCLUDED.ip_address,
+ location = EXCLUDED.location,
+ comment = EXCLUDED.comment,
+ firmware_version = EXCLUDED.firmware_version,
+ milestone_recording_server_id = EXCLUDED.milestone_recording_server_id,
+ milestone_display_name = EXCLUDED.milestone_display_name,
+ milestone_enabled = EXCLUDED.milestone_enabled,
+ milestone_synced_at = now(),
+ updated_at = now()
+ `,
+ inventoryNumber,
+ hardwareDevice.Manufacturer,
+ hardwareDevice.DeviceModelName,
+ hardwareDevice.SerialNumber,
+ hardwareDevice.MacAddress,
+ hardwareDevice.IPAddress,
+ buildMilestoneDeviceComment(hardwareDevice),
+ hardwareDevice.FirmwareVersion,
+ hardwareDevice.RecordingServerID,
+ hardwareDevice.HardwareID,
+ hardwareDevice.DisplayName,
+ hardwareDevice.Enabled,
+ )
+
+ if err != nil {
+ return false, err
+ }
+
+ return commandTag.RowsAffected() > 0, nil
+}
+
+func normalizeMilestoneManufacturer(value string, model string) string {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return ""
+ }
+
+ model = strings.TrimSpace(model)
+ normalizedValue := strings.ToLower(value)
+
+ if strings.EqualFold(value, model) ||
+ strings.HasSuffix(normalizedValue, " device") ||
+ strings.Contains(normalizedValue, " camera") ||
+ strings.Contains(normalizedValue, " encoder") ||
+ strings.Contains(normalizedValue, " video") {
+ if manufacturer := extractMilestoneManufacturerFromName(value); manufacturer != "" {
+ return manufacturer
+ }
+ }
+
+ return value
+}
+
+func extractMilestoneManufacturerFromName(value string) string {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return ""
+ }
+
+ parts := strings.FieldsFunc(value, func(r rune) bool {
+ return unicode.IsSpace(r) ||
+ r == '_' ||
+ r == '-' ||
+ r == '/' ||
+ r == '\\' ||
+ r == '(' ||
+ r == ')' ||
+ r == ','
+ })
+
+ ignoredWords := map[string]bool{
+ "device": true,
+ "camera": true,
+ "video": true,
+ "encoder": true,
+ "ip": true,
+ "network": true,
+ "hardware": true,
+ }
+
+ for _, part := range parts {
+ part = strings.TrimSpace(part)
+
+ if part == "" {
+ continue
+ }
+
+ if isOnlyDigits(part) {
+ continue
+ }
+
+ if ignoredWords[strings.ToLower(part)] {
+ continue
+ }
+
+ return part
+ }
+
+ return ""
+}
+
+func buildMilestoneInventoryNumber(
+ ctx context.Context,
+ tx pgx.Tx,
+ hardwareDevice milestoneHardwareDevice,
+) (string, error) {
+ inventoryNumber := extractInventoryNumberFromMilestoneDeviceName(
+ hardwareDevice.DeviceName,
+ )
+
+ if inventoryNumber == "" {
+ inventoryNumber = extractInventoryNumberFromMilestoneDeviceName(
+ hardwareDevice.DisplayName,
+ )
+ }
+
+ if inventoryNumber == "" {
+ inventoryNumber = buildGeneratedMilestoneInventoryNumber(
+ hardwareDevice.HardwareID,
+ )
+ }
+
+ var exists bool
+
+ err := tx.QueryRow(
+ ctx,
+ `
+ SELECT EXISTS(
+ SELECT 1
+ FROM devices
+ WHERE lower(inventory_number) = lower($1)
+ AND milestone_hardware_id <> $2
+ )
+ `,
+ inventoryNumber,
+ hardwareDevice.HardwareID,
+ ).Scan(&exists)
+ if err != nil {
+ return "", err
+ }
+
+ if !exists {
+ return inventoryNumber, nil
+ }
+
+ suffix := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(hardwareDevice.HardwareID), "-", ""))
+ if len(suffix) > 8 {
+ suffix = suffix[:8]
+ }
+
+ if suffix == "" {
+ suffix = "MS"
+ }
+
+ return inventoryNumber + "-" + suffix, nil
+}
+
+func extractInventoryNumberFromMilestoneDeviceName(value string) string {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return ""
+ }
+
+ parts := strings.FieldsFunc(value, func(r rune) bool {
+ return r == '_' || r == '-' || unicode.IsSpace(r)
+ })
+
+ for _, part := range parts {
+ part = strings.TrimSpace(part)
+
+ if part == "" {
+ continue
+ }
+
+ if isOnlyDigits(part) {
+ return part
+ }
+ }
+
+ return ""
+}
+
+func isOnlyDigits(value string) bool {
+ if value == "" {
+ return false
+ }
+
+ for _, r := range value {
+ if r < '0' || r > '9' {
+ return false
+ }
+ }
+
+ return true
+}
+
+func buildGeneratedMilestoneInventoryNumber(hardwareID string) string {
+ value := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(hardwareID), "-", ""))
+
+ if len(value) > 12 {
+ value = value[:12]
+ }
+
+ if value == "" {
+ value = "UNKNOWN"
+ }
+
+ return "MS-" + value
+}
+
+func buildMilestoneDeviceComment(hardwareDevice milestoneHardwareDevice) string {
+ parts := []string{}
+
+ if strings.TrimSpace(hardwareDevice.DisplayName) != "" {
+ parts = append(parts, strings.TrimSpace(hardwareDevice.DisplayName))
+ }
+
+ if strings.TrimSpace(hardwareDevice.DeviceName) != "" &&
+ strings.TrimSpace(hardwareDevice.DeviceName) != strings.TrimSpace(hardwareDevice.DisplayName) {
+ parts = append(parts, "DeviceName: "+strings.TrimSpace(hardwareDevice.DeviceName))
+ }
+
+ if hardwareDevice.Enabled {
+ parts = append(parts, "Status: aktiviert")
+ } else {
+ parts = append(parts, "Status: deaktiviert")
+ }
+
+ if len(parts) == 0 {
+ return "Automatisch aus Milestone synchronisiert."
+ }
+
+ return fmt.Sprintf(
+ "Automatisch aus Milestone synchronisiert: %s",
+ strings.Join(parts, " · "),
+ )
+}
diff --git a/backend/milestone_hardware.go b/backend/milestone_hardware.go
new file mode 100644
index 0000000..55d9c96
--- /dev/null
+++ b/backend/milestone_hardware.go
@@ -0,0 +1,338 @@
+// backend/milestone_hardware.go
+
+package main
+
+import (
+ "bytes"
+ "context"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+)
+
+type milestoneRuntimeConfig struct {
+ Host string
+ AccessToken string
+ TokenType string
+ SkipTLSVerify bool
+ TokenExpiresAt *time.Time
+}
+
+type milestoneToggleDevice struct {
+ ID string
+ InventoryNumber string
+ MilestoneHardwareID string
+ MilestoneEnabled bool
+}
+
+func (s *Server) handleToggleMilestoneDeviceStatus(w http.ResponseWriter, r *http.Request) {
+ user, ok := userFromContext(r.Context())
+ if !ok {
+ writeError(w, http.StatusUnauthorized, "Unauthorized")
+ return
+ }
+
+ deviceID := strings.TrimSpace(r.PathValue("id"))
+ if deviceID == "" {
+ writeError(w, http.StatusBadRequest, "Geräte-ID fehlt")
+ return
+ }
+
+ device, err := s.getMilestoneToggleDevice(r.Context(), deviceID)
+ if errors.Is(err, pgx.ErrNoRows) {
+ writeError(w, http.StatusNotFound, "Gerät wurde nicht gefunden")
+ return
+ }
+
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Gerät konnte nicht geladen werden")
+ return
+ }
+
+ if device.MilestoneHardwareID == "" {
+ writeError(w, http.StatusBadRequest, "Dieses Gerät ist kein Milestone-Gerät")
+ return
+ }
+
+ config, err := s.getMilestoneRuntimeConfig(r.Context())
+ if errors.Is(err, pgx.ErrNoRows) {
+ writeError(w, http.StatusBadRequest, "Milestone-Einstellungen sind noch nicht hinterlegt")
+ return
+ }
+
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Milestone-Einstellungen konnten nicht geladen werden")
+ return
+ }
+
+ if config.Host == "" || config.AccessToken == "" {
+ writeError(w, http.StatusBadRequest, "Milestone Host oder Token fehlt")
+ return
+ }
+
+ if config.TokenExpiresAt != nil && time.Now().After(config.TokenExpiresAt.Add(-30*time.Second)) {
+ writeError(w, http.StatusBadRequest, "Milestone-Token ist abgelaufen. Bitte Token neu abrufen.")
+ return
+ }
+
+ nextEnabled := !device.MilestoneEnabled
+
+ if err := patchMilestoneHardware(
+ r.Context(),
+ config,
+ device.MilestoneHardwareID,
+ map[string]bool{
+ "enabled": nextEnabled,
+ },
+ ); err != nil {
+ s.logAndWriteUserError(
+ w,
+ r,
+ user,
+ http.StatusBadGateway,
+ "milestone_hardware",
+ "patch_hardware",
+ "Milestone-Status konnte nicht geändert werden",
+ err,
+ LogFields{
+ "deviceId": device.ID,
+ "inventoryNumber": device.InventoryNumber,
+ "milestoneHardwareId": device.MilestoneHardwareID,
+ "currentEnabled": device.MilestoneEnabled,
+ "nextEnabled": nextEnabled,
+ "payload": LogFields{
+ "enabled": nextEnabled,
+ },
+ },
+ )
+ return
+ }
+
+ _, err = s.db.Exec(
+ r.Context(),
+ `
+ UPDATE devices
+ SET
+ milestone_enabled = $2,
+ milestone_synced_at = now(),
+ updated_at = now()
+ WHERE id = $1
+ `,
+ device.ID,
+ nextEnabled,
+ )
+ if err != nil {
+ s.logAndWriteUserError(
+ w,
+ r,
+ user,
+ http.StatusInternalServerError,
+ "milestone_hardware",
+ "update_local_device",
+ "Milestone-Status konnte lokal nicht aktualisiert werden",
+ err,
+ LogFields{
+ "deviceId": device.ID,
+ "inventoryNumber": device.InventoryNumber,
+ "milestoneHardwareId": device.MilestoneHardwareID,
+ "nextEnabled": nextEnabled,
+ },
+ )
+ return
+ }
+
+ oldValue := "Aktiviert"
+ newValue := "Deaktiviert"
+
+ if nextEnabled {
+ oldValue = "Deaktiviert"
+ newValue = "Aktiviert"
+ }
+
+ changes, _ := json.Marshal([]map[string]string{
+ {
+ "field": "milestoneEnabled",
+ "label": "Milestone-Status",
+ "oldValue": oldValue,
+ "newValue": newValue,
+ },
+ })
+
+ _, _ = s.db.Exec(
+ r.Context(),
+ `
+ INSERT INTO device_history (
+ device_id,
+ user_id,
+ action,
+ changes
+ )
+ VALUES ($1, $2, 'updated', $3)
+ `,
+ device.ID,
+ user.ID,
+ changes,
+ )
+
+ if err := s.publishDeviceChangeEvent(
+ r.Context(),
+ user.ID,
+ device.ID,
+ "device.milestone.status",
+ "Milestone-Status geändert",
+ formatText(
+ "Gerät %s wurde in Milestone %s.",
+ device.InventoryNumber,
+ strings.ToLower(newValue),
+ ),
+ LogFields{
+ "inventoryNumber": device.InventoryNumber,
+ "milestoneHardwareId": device.MilestoneHardwareID,
+ "milestoneEnabled": nextEnabled,
+ },
+ ); err != nil {
+ logError("Device-SSE-Event konnte nicht gesendet werden", err, LogFields{
+ "deviceId": device.ID,
+ "type": "device.milestone.status",
+ })
+ }
+
+ writeJSON(w, http.StatusOK, map[string]any{
+ "ok": true,
+ "id": device.ID,
+ "milestoneEnabled": nextEnabled,
+ })
+}
+
+func (s *Server) getMilestoneToggleDevice(
+ ctx context.Context,
+ deviceID string,
+) (milestoneToggleDevice, error) {
+ var device milestoneToggleDevice
+
+ err := s.db.QueryRow(
+ ctx,
+ `
+ SELECT
+ id::TEXT,
+ inventory_number,
+ COALESCE(milestone_hardware_id, ''),
+ milestone_enabled
+ FROM devices
+ WHERE id = $1
+ LIMIT 1
+ `,
+ deviceID,
+ ).Scan(
+ &device.ID,
+ &device.InventoryNumber,
+ &device.MilestoneHardwareID,
+ &device.MilestoneEnabled,
+ )
+
+ return device, err
+}
+
+func (s *Server) getMilestoneRuntimeConfig(
+ ctx context.Context,
+) (milestoneRuntimeConfig, error) {
+ var config milestoneRuntimeConfig
+ var tokenExpiresAt sql.NullTime
+
+ err := s.db.QueryRow(
+ ctx,
+ `
+ SELECT
+ host,
+ COALESCE(pgp_sym_decrypt(access_token_encrypted, $1), ''),
+ COALESCE(token_type, ''),
+ skip_tls_verify,
+ token_expires_at
+ FROM milestone_settings
+ WHERE id = 1
+ LIMIT 1
+ `,
+ s.milestoneEncryptionKey(),
+ ).Scan(
+ &config.Host,
+ &config.AccessToken,
+ &config.TokenType,
+ &config.SkipTLSVerify,
+ &tokenExpiresAt,
+ )
+ if err != nil {
+ return config, err
+ }
+
+ if tokenExpiresAt.Valid {
+ config.TokenExpiresAt = &tokenExpiresAt.Time
+ }
+
+ return config, nil
+}
+
+func patchMilestoneHardware(
+ ctx context.Context,
+ config milestoneRuntimeConfig,
+ hardwareID string,
+ payload any,
+) error {
+ body, err := json.Marshal(payload)
+ if err != nil {
+ return err
+ }
+
+ requestURL := strings.TrimRight(config.Host, "/") +
+ "/API/rest/v1/hardware/" +
+ url.PathEscape(hardwareID)
+
+ request, err := http.NewRequestWithContext(
+ ctx,
+ http.MethodPatch,
+ requestURL,
+ bytes.NewReader(body),
+ )
+ if err != nil {
+ return err
+ }
+
+ tokenType := strings.TrimSpace(config.TokenType)
+ if tokenType == "" {
+ tokenType = "Bearer"
+ }
+
+ request.Header.Set("Authorization", tokenType+" "+config.AccessToken)
+ request.Header.Set("Accept", "application/json")
+ request.Header.Set("Content-Type", "application/json")
+
+ response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request)
+ if err != nil {
+ return err
+ }
+ defer response.Body.Close()
+
+ responseBody, _ := io.ReadAll(response.Body)
+
+ if response.StatusCode < 200 || response.StatusCode >= 300 {
+ return newAppErrorf(
+ "milestone hardware patch failed",
+ LogFields{
+ "statusCode": response.StatusCode,
+ "responseBody": string(responseBody),
+ "hardwareId": hardwareID,
+ },
+ "milestone hardware patch failed: status=%d body=%s",
+ response.StatusCode,
+ string(responseBody),
+ )
+ }
+
+ return nil
+}
diff --git a/backend/notification_preferences.go b/backend/notification_preferences.go
new file mode 100644
index 0000000..486ad5e
--- /dev/null
+++ b/backend/notification_preferences.go
@@ -0,0 +1,160 @@
+// backend\notification_preferences.go
+
+package main
+
+import (
+ "errors"
+ "net/http"
+
+ "github.com/jackc/pgx/v5"
+)
+
+type notificationPreferencesResponse struct {
+ InAppEnabled bool `json:"inAppEnabled"`
+ SoundEnabled bool `json:"soundEnabled"`
+ EmailEnabled bool `json:"emailEnabled"`
+ OperationUpdates bool `json:"operationUpdates"`
+ OperationJournals bool `json:"operationJournals"`
+ DeviceUpdates bool `json:"deviceUpdates"`
+ DeviceJournals bool `json:"deviceJournals"`
+ SystemMessages bool `json:"systemMessages"`
+}
+
+func defaultNotificationPreferences() notificationPreferencesResponse {
+ return notificationPreferencesResponse{
+ InAppEnabled: true,
+ SoundEnabled: false,
+ EmailEnabled: false,
+ OperationUpdates: true,
+ OperationJournals: true,
+ DeviceUpdates: true,
+ DeviceJournals: true,
+ SystemMessages: true,
+ }
+}
+
+func (s *Server) handleGetNotificationPreferences(w http.ResponseWriter, r *http.Request) {
+ user, ok := userFromContext(r.Context())
+ if !ok {
+ writeError(w, http.StatusUnauthorized, "Unauthorized")
+ return
+ }
+
+ settings, err := s.getNotificationPreferences(r, user.ID)
+ if errors.Is(err, pgx.ErrNoRows) {
+ writeJSON(w, http.StatusOK, map[string]any{
+ "settings": defaultNotificationPreferences(),
+ })
+ return
+ }
+
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Benachrichtigungseinstellungen konnten nicht geladen werden")
+ return
+ }
+
+ writeJSON(w, http.StatusOK, map[string]any{
+ "settings": settings,
+ })
+}
+
+func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *http.Request) {
+ user, ok := userFromContext(r.Context())
+ if !ok {
+ writeError(w, http.StatusUnauthorized, "Unauthorized")
+ return
+ }
+
+ var input notificationPreferencesResponse
+
+ if err := readJSON(r, &input); err != nil {
+ writeError(w, http.StatusBadRequest, "Invalid JSON")
+ return
+ }
+
+ _, err := s.db.Exec(
+ r.Context(),
+ `
+ INSERT INTO notification_preferences (
+ user_id,
+ in_app_enabled,
+ sound_enabled,
+ email_enabled,
+ operation_updates,
+ operation_journals,
+ device_updates,
+ device_journals,
+ system_messages
+ )
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
+ ON CONFLICT (user_id)
+ DO UPDATE SET
+ in_app_enabled = EXCLUDED.in_app_enabled,
+ sound_enabled = EXCLUDED.sound_enabled,
+ email_enabled = EXCLUDED.email_enabled,
+ operation_updates = EXCLUDED.operation_updates,
+ operation_journals = EXCLUDED.operation_journals,
+ device_updates = EXCLUDED.device_updates,
+ device_journals = EXCLUDED.device_journals,
+ system_messages = EXCLUDED.system_messages,
+ updated_at = now()
+ `,
+ user.ID,
+ input.InAppEnabled,
+ input.SoundEnabled,
+ input.EmailEnabled,
+ input.OperationUpdates,
+ input.OperationJournals,
+ input.DeviceUpdates,
+ input.DeviceJournals,
+ input.SystemMessages,
+ )
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Benachrichtigungseinstellungen konnten nicht gespeichert werden")
+ return
+ }
+
+ settings, err := s.getNotificationPreferences(r, user.ID)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Benachrichtigungseinstellungen wurden gespeichert, konnten aber nicht neu geladen werden")
+ return
+ }
+
+ writeJSON(w, http.StatusOK, map[string]any{
+ "settings": settings,
+ })
+}
+
+func (s *Server) getNotificationPreferences(r *http.Request, userID string) (notificationPreferencesResponse, error) {
+ settings := defaultNotificationPreferences()
+
+ err := s.db.QueryRow(
+ r.Context(),
+ `
+ SELECT
+ in_app_enabled,
+ sound_enabled,
+ email_enabled,
+ operation_updates,
+ operation_journals,
+ device_updates,
+ device_journals,
+ system_messages
+ FROM notification_preferences
+ WHERE user_id = $1
+ LIMIT 1
+ `,
+ userID,
+ ).Scan(
+ &settings.InAppEnabled,
+ &settings.SoundEnabled,
+ &settings.EmailEnabled,
+ &settings.OperationUpdates,
+ &settings.OperationJournals,
+ &settings.DeviceUpdates,
+ &settings.DeviceJournals,
+ &settings.SystemMessages,
+ )
+
+ return settings, err
+}
diff --git a/backend/notifications.go b/backend/notifications.go
index 89f6edd..2b7040b 100644
--- a/backend/notifications.go
+++ b/backend/notifications.go
@@ -144,7 +144,7 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
r.Context(),
`
SELECT
- id,
+ id::TEXT,
user_id::TEXT,
type,
title,
@@ -152,6 +152,7 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
entity_type,
COALESCE(entity_id::TEXT, ''),
data,
+ visible,
read_at,
created_at
FROM notifications
@@ -185,6 +186,7 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
¬ification.EntityType,
¬ification.EntityID,
&data,
+ ¬ification.Visible,
&readAt,
¬ification.CreatedAt,
); err != nil {
@@ -283,9 +285,13 @@ func (s *Server) createOperationNotification(
rows, err := s.db.Query(
ctx,
`
- SELECT id
+ SELECT id::TEXT
FROM users
WHERE id <> $1
+ AND (
+ 'admin' = ANY(rights)
+ OR 'operations:read' = ANY(rights)
+ )
`,
actor.ID,
)
@@ -294,18 +300,13 @@ func (s *Server) createOperationNotification(
}
defer rows.Close()
- recipientIDs := []string{}
-
for rows.Next() {
- var userID string
- if err := rows.Scan(&userID); err != nil {
+ var recipientID string
+
+ if err := rows.Scan(&recipientID); err != nil {
return err
}
- recipientIDs = append(recipientIDs, userID)
- }
-
- for _, recipientID := range recipientIDs {
var notification Notification
var rawData []byte
@@ -319,11 +320,21 @@ func (s *Server) createOperationNotification(
message,
entity_type,
entity_id,
- data
+ data,
+ visible
+ )
+ VALUES (
+ $1,
+ $2,
+ $3,
+ $4,
+ 'operation',
+ $5,
+ $6::JSONB,
+ true
)
- VALUES ($1, $2, $3, $4, 'operation', $5, $6::JSONB)
RETURNING
- id,
+ id::TEXT,
user_id::TEXT,
type,
title,
@@ -331,6 +342,7 @@ func (s *Server) createOperationNotification(
entity_type,
COALESCE(entity_id::TEXT, ''),
data,
+ visible,
created_at
`,
recipientID,
@@ -348,6 +360,109 @@ func (s *Server) createOperationNotification(
¬ification.EntityType,
¬ification.EntityID,
&rawData,
+ ¬ification.Visible,
+ ¬ification.CreatedAt,
+ )
+
+ if err != nil {
+ return err
+ }
+
+ notification.Data = json.RawMessage(rawData)
+
+ notificationsBroker.publish(notification)
+ }
+
+ return rows.Err()
+}
+
+func (s *Server) createOperationUpdateEvent(
+ ctx context.Context,
+ actor User,
+ operation Operation,
+ data map[string]any,
+) error {
+ dataJSON, err := json.Marshal(data)
+ if err != nil {
+ return err
+ }
+
+ rows, err := s.db.Query(
+ ctx,
+ `
+ SELECT id
+ FROM users
+ WHERE id <> $1
+ AND (
+ 'admin' = ANY(rights)
+ OR 'operations:read' = ANY(rights)
+ )
+ `,
+ actor.ID,
+ )
+ if err != nil {
+ return err
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var recipientID string
+
+ if err := rows.Scan(&recipientID); err != nil {
+ return err
+ }
+
+ var notification Notification
+ var rawData []byte
+
+ err := s.db.QueryRow(
+ ctx,
+ `
+ INSERT INTO notifications (
+ user_id,
+ type,
+ title,
+ message,
+ entity_type,
+ entity_id,
+ data,
+ visible
+ )
+ VALUES (
+ $1,
+ 'operation.updated',
+ 'Einsatz geändert',
+ '',
+ 'operation',
+ $2,
+ $3::JSONB,
+ false
+ )
+ RETURNING
+ id::TEXT,
+ user_id::TEXT,
+ type,
+ title,
+ message,
+ entity_type,
+ COALESCE(entity_id::TEXT, ''),
+ data,
+ visible,
+ created_at
+ `,
+ recipientID,
+ operation.ID,
+ string(dataJSON),
+ ).Scan(
+ ¬ification.ID,
+ ¬ification.UserID,
+ ¬ification.Type,
+ ¬ification.Title,
+ ¬ification.Message,
+ ¬ification.EntityType,
+ ¬ification.EntityID,
+ &rawData,
+ ¬ification.Visible,
¬ification.CreatedAt,
)
@@ -359,7 +474,7 @@ func (s *Server) createOperationNotification(
notificationsBroker.publish(notification)
}
- return nil
+ return rows.Err()
}
func (s *Server) createOperationJournalEvent(
diff --git a/backend/operations.go b/backend/operations.go
index da0fa62..4e21445 100644
--- a/backend/operations.go
+++ b/backend/operations.go
@@ -500,20 +500,19 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) {
return
}
- if err := s.createOperationNotification(
- r.Context(),
- user,
- operation,
- "operation.updated",
- "Einsatz geändert",
- fmt.Sprintf("Einsatz %s wurde geändert.", operation.OperationNumber),
- map[string]any{
- "operationNumber": operation.OperationNumber,
- "operationName": operation.OperationName,
- "changes": changes,
- },
- ); err != nil {
- // nicht abbrechen, Änderung wurde bereits gespeichert
+ if len(changes) > 0 {
+ if err := s.createOperationUpdateEvent(
+ r.Context(),
+ user,
+ operation,
+ map[string]any{
+ "operationNumber": operation.OperationNumber,
+ "operationName": operation.OperationName,
+ "changes": changes,
+ },
+ ); err != nil {
+ // nicht abbrechen, Änderung wurde bereits gespeichert
+ }
}
writeJSON(w, http.StatusOK, map[string]any{
diff --git a/backend/routes.go b/backend/routes.go
index 6b6a71f..c67a8ae 100644
--- a/backend/routes.go
+++ b/backend/routes.go
@@ -1,4 +1,4 @@
-// backend\routes.go
+// backend/routes.go
package main
@@ -16,17 +16,26 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("GET /settings/sessions", s.requireAuth(s.handleListUserSessions))
mux.HandleFunc("DELETE /settings/sessions/{id}", s.requireAuth(s.handleRevokeUserSession))
- mux.HandleFunc("PUT /settings/profile", s.requireAuth(s.handleUpdateSettingsProfile))
+ mux.HandleFunc("PUT /settings/account", s.requireAuth(s.handleUpdateSettingsAccount))
mux.HandleFunc("PUT /settings/password", s.requireAuth(s.handleUpdateSettingsPassword))
mux.HandleFunc("POST /settings/logout-other-sessions", s.requireAuth(s.handleLogoutOtherSessions))
mux.HandleFunc("DELETE /settings/account", s.requireAuth(s.handleDeleteOwnAccount))
+ mux.HandleFunc("GET /settings/notifications/preferences", s.requireAuth(s.handleGetNotificationPreferences))
+ mux.HandleFunc("PUT /settings/notifications/preferences", s.requireAuth(s.handleUpdateNotificationPreferences))
+
+ mux.HandleFunc("POST /feedback", s.requireAuth(s.handleCreateFeedback))
+
mux.HandleFunc("GET /devices", s.requireAuth(s.handleListDevices))
mux.HandleFunc("POST /devices", s.requireAuth(s.handleCreateDevice))
mux.HandleFunc("PUT /devices/{id}", s.requireAuth(s.handleUpdateDevice))
+ mux.HandleFunc("POST /devices/{id}/milestone-toggle", s.requireAuth(s.handleToggleMilestoneDeviceStatus))
mux.HandleFunc("GET /devices/{id}/history", s.requireAuth(s.handleListDeviceHistory))
mux.HandleFunc("POST /devices/{id}/history", s.requireAuth(s.handleCreateDeviceJournalEntry))
+ mux.HandleFunc("GET /devices/firmware-check", s.requireAuth(s.handleGetCameraFirmwareCheckState))
+ mux.HandleFunc("POST /devices/firmware-check", s.requireAuth(s.handleCheckCameraFirmware))
+
mux.HandleFunc("GET /device-lookups", s.handleDeviceLookups)
mux.HandleFunc("POST /device-manufacturers", s.handleCreateDeviceManufacturer)
mux.HandleFunc("POST /device-locations", s.handleCreateDeviceLocation)
@@ -52,6 +61,9 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("GET /dashboard/operation-changes", s.requireAuth(s.requireRight("operations:read", s.handleDashboardOperationChanges)))
+ mux.HandleFunc("GET /dashboard/settings", s.requireAuth(s.handleGetDashboardSettings))
+ mux.HandleFunc("PUT /dashboard/settings", s.requireAuth(s.handleUpdateDashboardSettings))
+
mux.HandleFunc("GET /dashboard/widgets", s.requireAuth(s.handleListDashboardWidgets))
mux.HandleFunc("POST /dashboard/widgets", s.requireAuth(s.handleCreateDashboardWidget))
mux.HandleFunc("PUT /dashboard/widgets/{id}", s.requireAuth(s.handleUpdateDashboardWidget))
@@ -67,11 +79,15 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("POST /admin/teams", s.requireAuth(s.requireRight("teams:write", s.handleAdminCreateTeam)))
mux.HandleFunc("PUT /admin/teams/{id}", s.requireAuth(s.requireRight("teams:write", s.handleAdminUpdateTeam)))
+ mux.HandleFunc("GET /admin/feedback", s.requireAuth(s.requireRight("administration:read", s.handleListFeedback)))
+ mux.HandleFunc("DELETE /admin/feedback/{id}", s.requireAuth(s.requireRight("administration:write", s.handleDeleteFeedback)))
+
/* milestone settings */
mux.HandleFunc("GET /admin/milestone", s.requireAuth(s.requireRight("administration:read", s.handleGetMilestoneSettings)))
mux.HandleFunc("PUT /admin/milestone", s.requireAuth(s.requireRight("administration:write", s.handleUpdateMilestoneSettings)))
mux.HandleFunc("POST /admin/milestone/token", s.requireAuth(s.requireRight("administration:write", s.handleFetchMilestoneToken)))
+ mux.HandleFunc("POST /admin/milestone/token/stream", s.requireAuth(s.requireRight("administration:write", s.handleFetchMilestoneTokenStream)))
return s.cors(mux)
}
diff --git a/backend/search.go b/backend/search.go
index 46b8e94..2328617 100644
--- a/backend/search.go
+++ b/backend/search.go
@@ -7,6 +7,7 @@ import (
"net/http"
"strconv"
"strings"
+ "time"
)
type globalSearchResponse struct {
@@ -30,6 +31,13 @@ type globalSearchItem struct {
Data map[string]any `json:"data,omitempty"`
}
+type globalSearchOptions struct {
+ Query string
+ Limit int
+ ExactPhrase bool
+ CaseSensitive bool
+}
+
func (s *Server) handleGlobalSearch(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
@@ -38,7 +46,17 @@ func (s *Server) handleGlobalSearch(w http.ResponseWriter, r *http.Request) {
}
query := strings.TrimSpace(r.URL.Query().Get("q"))
- limit := parseSearchLimit(r.URL.Query().Get("limit"), 8)
+
+ options := globalSearchOptions{
+ Query: query,
+ Limit: parseSearchLimit(r.URL.Query().Get("limit"), 8),
+ ExactPhrase: parseSearchBool(r.URL.Query().Get("exact")),
+ CaseSensitive: parseSearchBool(r.URL.Query().Get("caseSensitive")),
+ }
+
+ if !strings.Contains(query, " ") {
+ options.ExactPhrase = false
+ }
if len([]rune(query)) < 2 {
writeJSON(w, http.StatusOK, globalSearchResponse{
@@ -48,10 +66,10 @@ func (s *Server) handleGlobalSearch(w http.ResponseWriter, r *http.Request) {
return
}
- groups := make([]globalSearchGroup, 0, 3)
+ groups := make([]globalSearchGroup, 0, 5)
if userCanSearch(user, "operations:read") {
- items, err := s.searchOperations(r.Context(), query, limit)
+ items, err := s.searchOperations(r.Context(), options)
if err != nil {
writeError(w, http.StatusInternalServerError, "Einsätze konnten nicht durchsucht werden")
return
@@ -66,8 +84,40 @@ func (s *Server) handleGlobalSearch(w http.ResponseWriter, r *http.Request) {
}
}
+ if userCanSearch(user, "operations:read") {
+ items, err := s.searchOperationJournalEntries(r.Context(), options)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Einsatz-Journale konnten nicht durchsucht werden")
+ return
+ }
+
+ if len(items) > 0 {
+ groups = append(groups, globalSearchGroup{
+ ID: "operation-journals",
+ Title: "Einsatz-Journale",
+ Items: items,
+ })
+ }
+ }
+
if userCanSearch(user, "devices:read") {
- items, err := s.searchDevices(r.Context(), query, limit)
+ items, err := s.searchDeviceJournalEntries(r.Context(), options)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "Geräte-Journale konnten nicht durchsucht werden")
+ return
+ }
+
+ if len(items) > 0 {
+ groups = append(groups, globalSearchGroup{
+ ID: "device-journals",
+ Title: "Geräte-Journale",
+ Items: items,
+ })
+ }
+ }
+
+ if userCanSearch(user, "devices:read") {
+ items, err := s.searchDevices(r.Context(), options)
if err != nil {
writeError(w, http.StatusInternalServerError, "Geräte konnten nicht durchsucht werden")
return
@@ -83,7 +133,7 @@ func (s *Server) handleGlobalSearch(w http.ResponseWriter, r *http.Request) {
}
if userCanSearch(user, "users:read") {
- items, err := s.searchUsers(r.Context(), query, limit)
+ items, err := s.searchUsers(r.Context(), options)
if err != nil {
writeError(w, http.StatusInternalServerError, "Benutzer konnten nicht durchsucht werden")
return
@@ -104,8 +154,8 @@ func (s *Server) handleGlobalSearch(w http.ResponseWriter, r *http.Request) {
})
}
-func (s *Server) searchOperations(ctx context.Context, query string, limit int) ([]globalSearchItem, error) {
- pattern := "%" + query + "%"
+func (s *Server) searchOperations(ctx context.Context, options globalSearchOptions) ([]globalSearchItem, error) {
+ patterns := buildSearchPatterns(options)
rows, err := s.db.Query(
ctx,
@@ -119,24 +169,47 @@ func (s *Server) searchOperations(ctx context.Context, query string, limit int)
operation_leader,
operation_team
FROM operations
- WHERE CONCAT_WS(
- ' ',
- operation_number,
- operation_name,
- offense,
- case_worker,
- target_object,
- kw_address,
- operation_leader,
- operation_team,
- legend,
- remark
- ) ILIKE $1
+ WHERE (
+ (
+ $3 = false
+ AND CONCAT_WS(
+ ' ',
+ operation_number,
+ operation_name,
+ offense,
+ case_worker,
+ target_object,
+ kw_address,
+ operation_leader,
+ operation_team,
+ legend,
+ remark
+ ) ILIKE ALL($1::TEXT[])
+ )
+ OR
+ (
+ $3 = true
+ AND CONCAT_WS(
+ ' ',
+ operation_number,
+ operation_name,
+ offense,
+ case_worker,
+ target_object,
+ kw_address,
+ operation_leader,
+ operation_team,
+ legend,
+ remark
+ ) LIKE ALL($1::TEXT[])
+ )
+ )
ORDER BY updated_at DESC
LIMIT $2
`,
- pattern,
- limit,
+ patterns,
+ options.Limit,
+ options.CaseSensitive,
)
if err != nil {
return nil, err
@@ -195,8 +268,8 @@ func (s *Server) searchOperations(ctx context.Context, query string, limit int)
return items, rows.Err()
}
-func (s *Server) searchDevices(ctx context.Context, query string, limit int) ([]globalSearchItem, error) {
- pattern := "%" + query + "%"
+func (s *Server) searchDevices(ctx context.Context, options globalSearchOptions) ([]globalSearchItem, error) {
+ patterns := buildSearchPatterns(options)
rows, err := s.db.Query(
ctx,
@@ -208,25 +281,52 @@ func (s *Server) searchDevices(ctx context.Context, query string, limit int) ([]
model,
serial_number,
mac_address,
- location,
- loan_status
- FROM devices
- WHERE CONCAT_WS(
- ' ',
- inventory_number,
- manufacturer,
- model,
- serial_number,
- mac_address,
+ COALESCE(ip_address, ''),
location,
loan_status,
- comment
- ) ILIKE $1
+ COALESCE(firmware_version, '')
+ FROM devices
+ WHERE (
+ (
+ $3 = false
+ AND CONCAT_WS(
+ ' ',
+ inventory_number,
+ manufacturer,
+ model,
+ serial_number,
+ mac_address,
+ COALESCE(ip_address, ''),
+ location,
+ loan_status,
+ comment,
+ COALESCE(firmware_version, '')
+ ) ILIKE ALL($1::TEXT[])
+ )
+ OR
+ (
+ $3 = true
+ AND CONCAT_WS(
+ ' ',
+ inventory_number,
+ manufacturer,
+ model,
+ serial_number,
+ mac_address,
+ COALESCE(ip_address, ''),
+ location,
+ loan_status,
+ comment,
+ COALESCE(firmware_version, '')
+ ) LIKE ALL($1::TEXT[])
+ )
+ )
ORDER BY updated_at DESC
LIMIT $2
`,
- pattern,
- limit,
+ patterns,
+ options.Limit,
+ options.CaseSensitive,
)
if err != nil {
return nil, err
@@ -242,8 +342,10 @@ func (s *Server) searchDevices(ctx context.Context, query string, limit int) ([]
var model string
var serialNumber string
var macAddress string
+ var ipAddress string
var location string
var loanStatus string
+ var firmwareVersion string
if err := rows.Scan(
&id,
@@ -252,8 +354,10 @@ func (s *Server) searchDevices(ctx context.Context, query string, limit int) ([]
&model,
&serialNumber,
&macAddress,
+ &ipAddress,
&location,
&loanStatus,
+ &firmwareVersion,
); err != nil {
return nil, err
}
@@ -268,8 +372,10 @@ func (s *Server) searchDevices(ctx context.Context, query string, limit int) ([]
model,
serialNumber,
macAddress,
+ ipAddress,
location,
loanStatus,
+ firmwareVersion,
),
Href: "/geraete",
Data: map[string]any{
@@ -281,8 +387,201 @@ func (s *Server) searchDevices(ctx context.Context, query string, limit int) ([]
return items, rows.Err()
}
-func (s *Server) searchUsers(ctx context.Context, query string, limit int) ([]globalSearchItem, error) {
- pattern := "%" + query + "%"
+func (s *Server) searchOperationJournalEntries(ctx context.Context, options globalSearchOptions) ([]globalSearchItem, error) {
+ patterns := buildSearchPatterns(options)
+
+ rows, err := s.db.Query(
+ ctx,
+ `
+ SELECT
+ operation_history.id::TEXT,
+ operation_history.operation_id::TEXT,
+ operations.operation_number,
+ operations.operation_name,
+ COALESCE(users.display_name, users.username, users.email, 'Unbekannt'),
+ COALESCE(journal_change.change->>'newValue', ''),
+ operation_history.created_at
+ FROM operation_history
+ INNER JOIN operations
+ ON operations.id = operation_history.operation_id
+ LEFT JOIN users
+ ON users.id = operation_history.user_id
+ CROSS JOIN LATERAL jsonb_array_elements(operation_history.changes) AS journal_change(change)
+ WHERE
+ journal_change.change->>'field' = 'journal'
+ AND (
+ (
+ $3 = false
+ AND journal_change.change->>'newValue' ILIKE ALL($1::TEXT[])
+ )
+ OR
+ (
+ $3 = true
+ AND journal_change.change->>'newValue' LIKE ALL($1::TEXT[])
+ )
+ )
+ ORDER BY operation_history.created_at DESC
+ LIMIT $2
+ `,
+ patterns,
+ options.Limit,
+ options.CaseSensitive,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ items := []globalSearchItem{}
+
+ for rows.Next() {
+ var id string
+ var operationID string
+ var operationNumber string
+ var operationName string
+ var userDisplayName string
+ var content string
+ var createdAt time.Time
+
+ if err := rows.Scan(
+ &id,
+ &operationID,
+ &operationNumber,
+ &operationName,
+ &userDisplayName,
+ &content,
+ &createdAt,
+ ); err != nil {
+ return nil, err
+ }
+
+ title := operationName
+ if strings.TrimSpace(title) == "" {
+ title = operationNumber
+ }
+
+ items = append(items, globalSearchItem{
+ ID: "operation-journal-" + id,
+ EntityID: operationID,
+ EntityType: "operationJournal",
+ Title: title,
+ Subtitle: joinSearchSubtitle(
+ formatSearchDate(createdAt),
+ userDisplayName,
+ truncateSearchText(content, 140),
+ ),
+ Href: "/einsaetze",
+ Data: map[string]any{
+ "historyId": id,
+ "operationId": operationID,
+ "operationNumber": operationNumber,
+ "journalCreatedAt": createdAt,
+ },
+ })
+ }
+
+ return items, rows.Err()
+}
+
+func (s *Server) searchDeviceJournalEntries(ctx context.Context, options globalSearchOptions) ([]globalSearchItem, error) {
+ patterns := buildSearchPatterns(options)
+
+ rows, err := s.db.Query(
+ ctx,
+ `
+ SELECT
+ device_history.id::TEXT,
+ device_history.device_id::TEXT,
+ devices.inventory_number,
+ devices.manufacturer,
+ devices.model,
+ COALESCE(users.display_name, users.username, users.email, 'Unbekannt'),
+ COALESCE(journal_change.change->>'newValue', ''),
+ device_history.created_at
+ FROM device_history
+ INNER JOIN devices
+ ON devices.id = device_history.device_id
+ LEFT JOIN users
+ ON users.id = device_history.user_id
+ CROSS JOIN LATERAL jsonb_array_elements(device_history.changes) AS journal_change(change)
+ WHERE
+ journal_change.change->>'field' = 'journal'
+ AND (
+ (
+ $3 = false
+ AND journal_change.change->>'newValue' ILIKE ALL($1::TEXT[])
+ )
+ OR
+ (
+ $3 = true
+ AND journal_change.change->>'newValue' LIKE ALL($1::TEXT[])
+ )
+ )
+ ORDER BY device_history.created_at DESC
+ LIMIT $2
+ `,
+ patterns,
+ options.Limit,
+ options.CaseSensitive,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ items := []globalSearchItem{}
+
+ for rows.Next() {
+ var id string
+ var deviceID string
+ var inventoryNumber string
+ var manufacturer string
+ var model string
+ var userDisplayName string
+ var content string
+ var createdAt time.Time
+
+ if err := rows.Scan(
+ &id,
+ &deviceID,
+ &inventoryNumber,
+ &manufacturer,
+ &model,
+ &userDisplayName,
+ &content,
+ &createdAt,
+ ); err != nil {
+ return nil, err
+ }
+
+ deviceName := joinSearchSubtitle(manufacturer, model)
+
+ items = append(items, globalSearchItem{
+ ID: "device-journal-" + id,
+ EntityID: deviceID,
+ EntityType: "deviceJournal",
+ Title: inventoryNumber,
+ Subtitle: joinSearchSubtitle(
+ deviceName,
+ formatSearchDate(createdAt),
+ userDisplayName,
+ truncateSearchText(content, 140),
+ ),
+ Href: "/geraete",
+ Data: map[string]any{
+ "historyId": id,
+ "deviceId": deviceID,
+ "inventoryNumber": inventoryNumber,
+ "journalCreatedAt": createdAt,
+ },
+ })
+ }
+
+ return items, rows.Err()
+}
+
+func (s *Server) searchUsers(ctx context.Context, options globalSearchOptions) ([]globalSearchItem, error) {
+ patterns := buildSearchPatterns(options)
rows, err := s.db.Query(
ctx,
@@ -291,23 +590,41 @@ func (s *Server) searchUsers(ctx context.Context, query string, limit int) ([]gl
id::TEXT,
COALESCE(username, ''),
COALESCE(display_name, ''),
- email,
+ COALESCE(email, ''),
COALESCE(unit, ''),
COALESCE(user_group, '')
FROM users
- WHERE CONCAT_WS(
- ' ',
- username,
- display_name,
- email,
- unit,
- user_group
- ) ILIKE $1
+ WHERE (
+ (
+ $3 = false
+ AND CONCAT_WS(
+ ' ',
+ username,
+ display_name,
+ email,
+ unit,
+ user_group
+ ) ILIKE ALL($1::TEXT[])
+ )
+ OR
+ (
+ $3 = true
+ AND CONCAT_WS(
+ ' ',
+ username,
+ display_name,
+ email,
+ unit,
+ user_group
+ ) LIKE ALL($1::TEXT[])
+ )
+ )
ORDER BY display_name ASC, username ASC, email ASC
LIMIT $2
`,
- pattern,
- limit,
+ patterns,
+ options.Limit,
+ options.CaseSensitive,
)
if err != nil {
return nil, err
@@ -377,6 +694,43 @@ func parseSearchLimit(value string, fallback int) int {
return limit
}
+func parseSearchBool(value string) bool {
+ value = strings.ToLower(strings.TrimSpace(value))
+
+ return value == "1" ||
+ value == "true" ||
+ value == "yes" ||
+ value == "on"
+}
+
+func buildSearchPatterns(options globalSearchOptions) []string {
+ query := strings.TrimSpace(options.Query)
+
+ if query == "" {
+ return []string{"%%"}
+ }
+
+ if options.ExactPhrase && strings.Contains(query, " ") {
+ return []string{"%" + query + "%"}
+ }
+
+ parts := strings.Fields(query)
+ patterns := make([]string, 0, len(parts))
+
+ for _, part := range parts {
+ part = strings.TrimSpace(part)
+ if part != "" {
+ patterns = append(patterns, "%"+part+"%")
+ }
+ }
+
+ if len(patterns) == 0 {
+ return []string{"%" + query + "%"}
+ }
+
+ return patterns
+}
+
func userCanSearch(user User, right string) bool {
for _, userRight := range user.Rights {
if userRight == "admin" || userRight == right {
@@ -399,3 +753,26 @@ func joinSearchSubtitle(values ...string) string {
return strings.Join(parts, " · ")
}
+
+func formatSearchDate(value time.Time) string {
+ if value.IsZero() {
+ return ""
+ }
+
+ return value.Format("02.01.2006 15:04")
+}
+
+func truncateSearchText(value string, maxLength int) string {
+ value = strings.TrimSpace(value)
+
+ if maxLength <= 0 {
+ return value
+ }
+
+ runes := []rune(value)
+ if len(runes) <= maxLength {
+ return value
+ }
+
+ return strings.TrimSpace(string(runes[:maxLength])) + "…"
+}
diff --git a/backend/setup/main.go b/backend/setup/main.go
index bd6ab7d..d12bfc2 100644
--- a/backend/setup/main.go
+++ b/backend/setup/main.go
@@ -3,6 +3,7 @@
package main
import (
+ "bufio"
"context"
"crypto/rand"
"encoding/base64"
@@ -46,6 +47,11 @@ func main() {
if *reset {
if err := dropDatabaseIfExists(ctx, parsedURL, databaseName); err != nil {
+ if errors.Is(err, errResetAborted) {
+ fmt.Println("Reset abgebrochen. Es wurden keine Daten gelöscht.")
+ return
+ }
+
log.Fatal(err)
}
}
@@ -74,6 +80,28 @@ func main() {
}
printAdminResult(password, created)
+ waitForExit()
+}
+
+var errResetAborted = errors.New("reset abgebrochen")
+
+func confirmDatabaseReset(databaseName string) (bool, error) {
+ reader := bufio.NewReader(os.Stdin)
+
+ fmt.Println("")
+ fmt.Println("WARNUNG: Der Datenbank-Reset löscht alle Daten unwiderruflich.")
+ fmt.Printf("Datenbank: %s\n", databaseName)
+ fmt.Println("")
+ fmt.Printf("Bitte gib den Datenbanknamen %q ein, um fortzufahren: ", databaseName)
+
+ input, err := reader.ReadString('\n')
+ if err != nil {
+ return false, err
+ }
+
+ input = strings.TrimSpace(input)
+
+ return input == databaseName, nil
}
func dropDatabaseIfExists(ctx context.Context, parsedURL *url.URL, databaseName string) error {
@@ -107,6 +135,15 @@ func dropDatabaseIfExists(ctx context.Context, parsedURL *url.URL, databaseName
return nil
}
+ confirmed, err := confirmDatabaseReset(databaseName)
+ if err != nil {
+ return err
+ }
+
+ if !confirmed {
+ return errResetAborted
+ }
+
fmt.Printf("Datenbank %q wird zurückgesetzt...\n", databaseName)
_, err = db.Exec(
@@ -259,6 +296,17 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
)
`,
+ `
+ CREATE TABLE IF NOT EXISTS dashboard_settings (
+ user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
+
+ settings JSONB NOT NULL DEFAULT '{}'::JSONB,
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ )
+ `,
+
`
CREATE TABLE IF NOT EXISTS devices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -268,11 +316,19 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
model TEXT NOT NULL DEFAULT '',
serial_number TEXT NOT NULL DEFAULT '',
mac_address TEXT NOT NULL DEFAULT '',
+ ip_address TEXT NOT NULL DEFAULT '',
location TEXT NOT NULL DEFAULT '',
loan_status TEXT NOT NULL DEFAULT 'Verfügbar',
is_bao_device BOOLEAN NOT NULL DEFAULT false,
comment TEXT NOT NULL DEFAULT '',
+ milestone_recording_server_id TEXT NOT NULL DEFAULT '',
+ milestone_hardware_id TEXT NOT NULL DEFAULT '',
+ milestone_display_name TEXT NOT NULL DEFAULT '',
+ firmware_version TEXT NOT NULL DEFAULT '',
+ milestone_enabled BOOLEAN NOT NULL DEFAULT true,
+ milestone_synced_at TIMESTAMPTZ,
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
@@ -414,6 +470,89 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
)
`,
+ `
+ CREATE TABLE IF NOT EXISTS notification_preferences (
+ user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
+
+ in_app_enabled BOOLEAN NOT NULL DEFAULT true,
+ sound_enabled BOOLEAN NOT NULL DEFAULT false,
+ email_enabled BOOLEAN NOT NULL DEFAULT false,
+
+ operation_updates BOOLEAN NOT NULL DEFAULT true,
+ operation_journals BOOLEAN NOT NULL DEFAULT true,
+ device_updates BOOLEAN NOT NULL DEFAULT true,
+ device_journals BOOLEAN NOT NULL DEFAULT true,
+ system_messages BOOLEAN NOT NULL DEFAULT true,
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ )
+ `,
+
+ `
+ CREATE TABLE IF NOT EXISTS feedback (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+
+ message TEXT NOT NULL,
+ log JSONB NOT NULL DEFAULT '{}'::JSONB,
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ )
+ `,
+
+ `
+ CREATE TABLE IF NOT EXISTS user_logs (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ user_id UUID REFERENCES users(id) ON DELETE SET NULL,
+
+ level TEXT NOT NULL DEFAULT 'info',
+ category TEXT NOT NULL DEFAULT '',
+ action TEXT NOT NULL DEFAULT '',
+
+ message TEXT NOT NULL DEFAULT '',
+ error TEXT NOT NULL DEFAULT '',
+
+ user_snapshot JSONB NOT NULL DEFAULT '{}'::JSONB,
+ request JSONB NOT NULL DEFAULT '{}'::JSONB,
+ details JSONB NOT NULL DEFAULT '{}'::JSONB,
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ )
+ `,
+
+ `
+ CREATE TABLE IF NOT EXISTS camera_firmware_cache (
+ manufacturer TEXT NOT NULL,
+ normalized_model TEXT NOT NULL,
+
+ latest_version TEXT NOT NULL DEFAULT '',
+ support_url TEXT NOT NULL DEFAULT '',
+ source TEXT NOT NULL DEFAULT '',
+ check_error TEXT NOT NULL DEFAULT '',
+ support_badges JSONB NOT NULL DEFAULT '[]'::JSONB,
+
+ checked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+
+ PRIMARY KEY (manufacturer, normalized_model)
+ )
+ `,
+
+ `
+ CREATE TABLE IF NOT EXISTS camera_firmware_refresh_state (
+ id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
+
+ last_checked_at TIMESTAMPTZ,
+ last_checked_by UUID REFERENCES users(id) ON DELETE SET NULL,
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ )
+ `,
+
`CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)`,
`CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)`,
@@ -428,9 +567,14 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
`CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_user_id ON dashboard_widgets(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_position ON dashboard_widgets(user_id, y, x)`,
+ `CREATE INDEX IF NOT EXISTS idx_dashboard_settings_user_id ON dashboard_settings(user_id)`,
+
`CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_inventory_number_unique ON devices(lower(inventory_number))`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_serial_number_unique ON devices(lower(serial_number)) WHERE serial_number <> ''`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_mac_address_unique ON devices(lower(mac_address)) WHERE mac_address <> ''`,
+ `CREATE INDEX IF NOT EXISTS idx_devices_ip_address ON devices(ip_address)`,
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_milestone_hardware_id_unique ON devices(milestone_hardware_id) WHERE milestone_hardware_id <> ''`,
+ `CREATE INDEX IF NOT EXISTS idx_devices_milestone_recording_server_id ON devices(milestone_recording_server_id)`,
`CREATE INDEX IF NOT EXISTS idx_devices_manufacturer ON devices(manufacturer)`,
`CREATE INDEX IF NOT EXISTS idx_devices_model ON devices(model)`,
@@ -462,6 +606,19 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
`CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at)`,
`CREATE INDEX IF NOT EXISTS idx_notifications_read_at ON notifications(read_at)`,
`CREATE INDEX IF NOT EXISTS idx_notifications_entity ON notifications(entity_type, entity_id)`,
+
+ `CREATE INDEX IF NOT EXISTS idx_notification_preferences_user_id ON notification_preferences(user_id)`,
+
+ `CREATE INDEX IF NOT EXISTS idx_feedback_user_id ON feedback(user_id)`,
+ `CREATE INDEX IF NOT EXISTS idx_feedback_created_at ON feedback(created_at)`,
+
+ `CREATE INDEX IF NOT EXISTS idx_user_logs_user_id ON user_logs(user_id)`,
+ `CREATE INDEX IF NOT EXISTS idx_user_logs_level ON user_logs(level)`,
+ `CREATE INDEX IF NOT EXISTS idx_user_logs_category ON user_logs(category)`,
+ `CREATE INDEX IF NOT EXISTS idx_user_logs_created_at ON user_logs(created_at)`,
+ `CREATE INDEX IF NOT EXISTS idx_user_logs_user_created_at ON user_logs(user_id, created_at DESC, id DESC)`,
+
+ `CREATE INDEX IF NOT EXISTS idx_camera_firmware_cache_checked_at ON camera_firmware_cache(checked_at)`,
}
for _, query := range queries {
@@ -728,3 +885,11 @@ func env(key string, fallback string) string {
return value
}
+
+func waitForExit() {
+ fmt.Println("")
+ fmt.Print("Drücke Enter, um das Setup zu beenden...")
+
+ reader := bufio.NewReader(os.Stdin)
+ _, _ = reader.ReadString('\n')
+}
diff --git a/backend/user_log.go b/backend/user_log.go
new file mode 100644
index 0000000..532b913
--- /dev/null
+++ b/backend/user_log.go
@@ -0,0 +1,400 @@
+// backend/user_log.go
+
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "net"
+ "net/http"
+ "strings"
+)
+
+type UserLogLevel string
+
+const (
+ UserLogLevelInfo UserLogLevel = "info"
+ UserLogLevelWarning UserLogLevel = "warning"
+ UserLogLevelError UserLogLevel = "error"
+)
+
+const (
+ maxUserLogEntriesPerUser = 500
+ maxAnonymousUserLogEntries = 1000
+)
+
+type UserLogEntry struct {
+ User *User
+ UserID string
+
+ Request *http.Request
+
+ Level UserLogLevel
+ Category string
+ Action string
+
+ Message string
+ Error error
+ Details LogFields
+}
+
+func (s *Server) logUserEvent(ctx context.Context, entry UserLogEntry) {
+ if entry.Level == "" {
+ entry.Level = UserLogLevelInfo
+ }
+
+ userID := any(nil)
+ userIDForPrune := ""
+ userSnapshot := map[string]any{}
+
+ if entry.User != nil {
+ userID = entry.User.ID
+ userIDForPrune = entry.User.ID
+ userSnapshot = buildUserLogSnapshot(*entry.User)
+ } else if strings.TrimSpace(entry.UserID) != "" {
+ userID = strings.TrimSpace(entry.UserID)
+ userIDForPrune = strings.TrimSpace(entry.UserID)
+ }
+
+ requestSnapshot := map[string]any{}
+ if entry.Request != nil {
+ requestSnapshot = buildRequestLogSnapshot(entry.Request)
+ }
+
+ details := mergeLogFields(appErrorFields(entry.Error), entry.Details)
+
+ errorMessage := ""
+ if entry.Error != nil {
+ errorMessage = entry.Error.Error()
+ }
+
+ userSnapshotJSON, err := json.Marshal(userSnapshot)
+ if err != nil {
+ logError("User-Log konnte nicht serialisiert werden", err, nil)
+ return
+ }
+
+ requestSnapshotJSON, err := json.Marshal(requestSnapshot)
+ if err != nil {
+ logError("Request-Log konnte nicht serialisiert werden", err, nil)
+ return
+ }
+
+ detailsJSON, err := json.Marshal(details)
+ if err != nil {
+ logError("User-Log-Details konnten nicht serialisiert werden", err, nil)
+ return
+ }
+
+ _, err = s.db.Exec(
+ ctx,
+ `
+ INSERT INTO user_logs (
+ user_id,
+ level,
+ category,
+ action,
+ message,
+ error,
+ user_snapshot,
+ request,
+ details
+ )
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
+ `,
+ userID,
+ string(entry.Level),
+ entry.Category,
+ entry.Action,
+ entry.Message,
+ errorMessage,
+ userSnapshotJSON,
+ requestSnapshotJSON,
+ detailsJSON,
+ )
+ if err != nil {
+ logError("User-Log konnte nicht gespeichert werden", err, LogFields{
+ "category": entry.Category,
+ "action": entry.Action,
+ })
+ return
+ }
+
+ if userIDForPrune != "" {
+ s.pruneUserLogsByUserID(ctx, userIDForPrune)
+ return
+ }
+
+ s.pruneAnonymousUserLogs(ctx)
+}
+
+func (s *Server) pruneUserLogs(ctx context.Context, user *User) {
+ if user == nil || user.ID == "" {
+ return
+ }
+
+ s.pruneUserLogsByUserID(ctx, user.ID)
+}
+
+func (s *Server) pruneUserLogsByUserID(ctx context.Context, userID string) {
+ userID = strings.TrimSpace(userID)
+
+ if userID == "" {
+ return
+ }
+
+ _, err := s.db.Exec(
+ ctx,
+ `
+ DELETE FROM user_logs
+ WHERE user_id = $1
+ AND id NOT IN (
+ SELECT id
+ FROM user_logs
+ WHERE user_id = $1
+ ORDER BY created_at DESC, id DESC
+ LIMIT $2
+ )
+ `,
+ userID,
+ maxUserLogEntriesPerUser,
+ )
+ if err != nil {
+ logError("Alte User-Logs konnten nicht gelöscht werden", err, LogFields{
+ "userId": userID,
+ "limit": maxUserLogEntriesPerUser,
+ })
+ }
+}
+
+func (s *Server) pruneAnonymousUserLogs(ctx context.Context) {
+ _, err := s.db.Exec(
+ ctx,
+ `
+ DELETE FROM user_logs
+ WHERE user_id IS NULL
+ AND id NOT IN (
+ SELECT id
+ FROM user_logs
+ WHERE user_id IS NULL
+ ORDER BY created_at DESC, id DESC
+ LIMIT $1
+ )
+ `,
+ maxAnonymousUserLogEntries,
+ )
+ if err != nil {
+ logError("Alte anonyme User-Logs konnten nicht gelöscht werden", err, LogFields{
+ "limit": maxAnonymousUserLogEntries,
+ })
+ }
+}
+
+func (s *Server) logUserError(
+ r *http.Request,
+ user User,
+ category string,
+ action string,
+ message string,
+ err error,
+ details LogFields,
+) {
+ s.logUserEvent(r.Context(), UserLogEntry{
+ User: &user,
+ Request: r,
+ Level: UserLogLevelError,
+ Category: category,
+ Action: action,
+ Message: message,
+ Error: err,
+ Details: details,
+ })
+}
+
+func (s *Server) logAndWriteUserError(
+ w http.ResponseWriter,
+ r *http.Request,
+ user User,
+ statusCode int,
+ category string,
+ action string,
+ publicMessage string,
+ err error,
+ details LogFields,
+) {
+ s.logUserError(
+ r,
+ user,
+ category,
+ action,
+ publicMessage,
+ err,
+ details,
+ )
+
+ writeError(w, statusCode, publicMessage)
+}
+
+func buildUserLogSnapshot(user User) map[string]any {
+ return map[string]any{
+ "id": user.ID,
+ "username": user.Username,
+ "displayName": user.DisplayName,
+ "email": user.Email,
+ "avatar": user.Avatar,
+ "unit": user.Unit,
+ "group": user.Group,
+ "rights": user.Rights,
+ "teams": user.Teams,
+ }
+}
+
+func buildRequestLogSnapshot(r *http.Request) map[string]any {
+ if r == nil {
+ return map[string]any{}
+ }
+
+ return map[string]any{
+ "method": r.Method,
+ "path": r.URL.Path,
+ "query": r.URL.RawQuery,
+ "ipAddress": requestIPAddress(r),
+ "remoteAddr": r.RemoteAddr,
+ "userAgent": r.UserAgent(),
+ "referer": r.Referer(),
+ }
+}
+
+func requestIPAddress(r *http.Request) string {
+ for _, headerName := range []string{
+ "X-Forwarded-For",
+ "X-Real-IP",
+ "CF-Connecting-IP",
+ } {
+ headerValue := strings.TrimSpace(r.Header.Get(headerName))
+ if headerValue == "" {
+ continue
+ }
+
+ ipAddress := strings.TrimSpace(strings.Split(headerValue, ",")[0])
+ if ipAddress != "" {
+ return ipAddress
+ }
+ }
+
+ host, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err == nil {
+ return host
+ }
+
+ return r.RemoteAddr
+}
+
+func (s *Server) logRequestEvent(
+ r *http.Request,
+ level UserLogLevel,
+ category string,
+ action string,
+ message string,
+ err error,
+ details LogFields,
+) {
+ var user *User
+ userID := ""
+
+ if currentUser, ok := userFromContext(r.Context()); ok {
+ user = ¤tUser
+ } else if currentUserID, ok := s.currentUserIDFromRequest(r); ok {
+ userID = currentUserID
+ }
+
+ s.logUserEvent(r.Context(), UserLogEntry{
+ User: user,
+ UserID: userID,
+ Request: r,
+ Level: level,
+ Category: category,
+ Action: action,
+ Message: message,
+ Error: err,
+ Details: details,
+ })
+}
+
+func (s *Server) logRequestInfo(
+ r *http.Request,
+ category string,
+ action string,
+ message string,
+ details LogFields,
+) {
+ s.logRequestEvent(
+ r,
+ UserLogLevelInfo,
+ category,
+ action,
+ message,
+ nil,
+ details,
+ )
+}
+
+func (s *Server) logRequestWarning(
+ r *http.Request,
+ category string,
+ action string,
+ message string,
+ err error,
+ details LogFields,
+) {
+ s.logRequestEvent(
+ r,
+ UserLogLevelWarning,
+ category,
+ action,
+ message,
+ err,
+ details,
+ )
+}
+
+func (s *Server) logRequestError(
+ r *http.Request,
+ category string,
+ action string,
+ message string,
+ err error,
+ details LogFields,
+) {
+ s.logRequestEvent(
+ r,
+ UserLogLevelError,
+ category,
+ action,
+ message,
+ err,
+ details,
+ )
+}
+
+func (s *Server) logAndWriteSettingsError(
+ w http.ResponseWriter,
+ r *http.Request,
+ statusCode int,
+ category string,
+ action string,
+ publicMessage string,
+ err error,
+ details LogFields,
+) {
+ s.logRequestError(
+ r,
+ category,
+ action,
+ publicMessage,
+ err,
+ details,
+ )
+
+ writeSettingsError(w, statusCode, publicMessage)
+}
diff --git a/backend/user_sessions.go b/backend/user_sessions.go
index c408eb4..d3e80dc 100644
--- a/backend/user_sessions.go
+++ b/backend/user_sessions.go
@@ -1,4 +1,4 @@
-// backend\user_sessions.go
+// backend/user_sessions.go
package main
@@ -35,6 +35,7 @@ func (s *Server) createUserSession(
operatingSystem := strings.TrimSpace(input.OperatingSystem)
deviceType := strings.TrimSpace(input.DeviceType)
userAgent := r.UserAgent()
+ ipAddress := getRequestIP(r)
if browser == "" {
browser = detectBrowserFromUserAgent(userAgent)
@@ -69,10 +70,45 @@ func (s *Server) createUserSession(
operatingSystem,
deviceType,
userAgent,
- getRequestIP(r),
+ ipAddress,
).Scan(&sessionID)
+ if err != nil {
+ s.logUserEvent(ctx, UserLogEntry{
+ UserID: userID,
+ Request: r,
+ Level: UserLogLevelError,
+ Category: "session",
+ Action: "create_session",
+ Message: "Sitzung konnte nicht erstellt werden",
+ Error: err,
+ Details: LogFields{
+ "browser": browser,
+ "operatingSystem": operatingSystem,
+ "deviceType": deviceType,
+ "ipAddress": ipAddress,
+ },
+ })
- return sessionID, err
+ return "", err
+ }
+
+ s.logUserEvent(ctx, UserLogEntry{
+ UserID: userID,
+ Request: r,
+ Level: UserLogLevelInfo,
+ Category: "session",
+ Action: "create_session",
+ Message: "Neue Sitzung wurde erstellt",
+ Details: LogFields{
+ "sessionId": sessionID,
+ "browser": browser,
+ "operatingSystem": operatingSystem,
+ "deviceType": deviceType,
+ "ipAddress": ipAddress,
+ },
+ })
+
+ return sessionID, nil
}
func (s *Server) isUserSessionActive(
@@ -124,6 +160,15 @@ func (s *Server) touchUserSession(
func (s *Server) handleListUserSessions(w http.ResponseWriter, r *http.Request) {
userID, ok := s.currentUserIDFromRequest(r)
if !ok {
+ s.logRequestWarning(
+ r,
+ "session",
+ "list_sessions",
+ "Sitzungen konnten nicht geladen werden: nicht angemeldet",
+ nil,
+ nil,
+ )
+
writeSettingsError(w, http.StatusUnauthorized, "Nicht angemeldet")
return
}
@@ -153,7 +198,19 @@ func (s *Server) handleListUserSessions(w http.ResponseWriter, r *http.Request)
currentSessionID,
)
if err != nil {
- writeSettingsError(w, http.StatusInternalServerError, "Sitzungen konnten nicht geladen werden")
+ s.logAndWriteSettingsError(
+ w,
+ r,
+ http.StatusInternalServerError,
+ "session",
+ "list_sessions",
+ "Sitzungen konnten nicht geladen werden",
+ err,
+ LogFields{
+ "userId": userID,
+ "currentSessionId": currentSessionID,
+ },
+ )
return
}
defer rows.Close()
@@ -173,7 +230,18 @@ func (s *Server) handleListUserSessions(w http.ResponseWriter, r *http.Request)
&session.CreatedAt,
&session.LastSeenAt,
); err != nil {
- writeSettingsError(w, http.StatusInternalServerError, "Sitzungen konnten nicht gelesen werden")
+ s.logAndWriteSettingsError(
+ w,
+ r,
+ http.StatusInternalServerError,
+ "session",
+ "scan_sessions",
+ "Sitzungen konnten nicht gelesen werden",
+ err,
+ LogFields{
+ "userId": userID,
+ },
+ )
return
}
@@ -182,10 +250,32 @@ func (s *Server) handleListUserSessions(w http.ResponseWriter, r *http.Request)
}
if err := rows.Err(); err != nil {
- writeSettingsError(w, http.StatusInternalServerError, "Sitzungen konnten nicht gelesen werden")
+ s.logAndWriteSettingsError(
+ w,
+ r,
+ http.StatusInternalServerError,
+ "session",
+ "read_sessions",
+ "Sitzungen konnten nicht gelesen werden",
+ err,
+ LogFields{
+ "userId": userID,
+ },
+ )
return
}
+ s.logRequestInfo(
+ r,
+ "session",
+ "list_sessions",
+ "Sitzungen wurden geladen",
+ LogFields{
+ "userId": userID,
+ "sessionCount": len(sessions),
+ },
+ )
+
writeSettingsJSON(w, http.StatusOK, map[string]any{
"sessions": sessions,
})
@@ -194,19 +284,52 @@ func (s *Server) handleListUserSessions(w http.ResponseWriter, r *http.Request)
func (s *Server) handleRevokeUserSession(w http.ResponseWriter, r *http.Request) {
userID, ok := s.currentUserIDFromRequest(r)
if !ok {
+ s.logRequestWarning(
+ r,
+ "session",
+ "revoke_session",
+ "Sitzung konnte nicht abgemeldet werden: nicht angemeldet",
+ nil,
+ nil,
+ )
+
writeSettingsError(w, http.StatusUnauthorized, "Nicht angemeldet")
return
}
- sessionID := r.PathValue("id")
+ sessionID := strings.TrimSpace(r.PathValue("id"))
currentSessionID, _ := s.currentSessionIDFromRequest(r)
if sessionID == "" {
+ s.logRequestWarning(
+ r,
+ "session",
+ "revoke_session",
+ "Sitzung konnte nicht abgemeldet werden: Sitzung fehlt",
+ nil,
+ LogFields{
+ "userId": userID,
+ },
+ )
+
writeSettingsError(w, http.StatusBadRequest, "Sitzung fehlt")
return
}
if sessionID == currentSessionID {
+ s.logRequestWarning(
+ r,
+ "session",
+ "revoke_session",
+ "Aktuelle Sitzung kann hier nicht abgemeldet werden",
+ nil,
+ LogFields{
+ "userId": userID,
+ "sessionId": sessionID,
+ "currentSessionId": currentSessionID,
+ },
+ )
+
writeSettingsError(w, http.StatusBadRequest, "Die aktuelle Sitzung kann hier nicht abgemeldet werden")
return
}
@@ -224,15 +347,50 @@ func (s *Server) handleRevokeUserSession(w http.ResponseWriter, r *http.Request)
userID,
)
if err != nil {
- writeSettingsError(w, http.StatusInternalServerError, "Sitzung konnte nicht abgemeldet werden")
+ s.logAndWriteSettingsError(
+ w,
+ r,
+ http.StatusInternalServerError,
+ "session",
+ "revoke_session",
+ "Sitzung konnte nicht abgemeldet werden",
+ err,
+ LogFields{
+ "userId": userID,
+ "sessionId": sessionID,
+ },
+ )
return
}
if commandTag.RowsAffected() == 0 {
+ s.logRequestWarning(
+ r,
+ "session",
+ "revoke_session",
+ "Sitzung wurde nicht gefunden",
+ nil,
+ LogFields{
+ "userId": userID,
+ "sessionId": sessionID,
+ },
+ )
+
writeSettingsError(w, http.StatusNotFound, "Sitzung wurde nicht gefunden")
return
}
+ s.logRequestInfo(
+ r,
+ "session",
+ "revoke_session",
+ "Sitzung wurde abgemeldet",
+ LogFields{
+ "userId": userID,
+ "sessionId": sessionID,
+ },
+ )
+
writeSettingsJSON(w, http.StatusOK, map[string]any{
"message": "Sitzung wurde abgemeldet",
})
@@ -316,7 +474,8 @@ func detectOperatingSystemFromUserAgent(userAgent string) string {
func detectDeviceTypeFromUserAgent(userAgent string) string {
switch {
- case strings.Contains(userAgent, "iPhone") || strings.Contains(userAgent, "Android") && strings.Contains(userAgent, "Mobile"):
+ case strings.Contains(userAgent, "iPhone") ||
+ (strings.Contains(userAgent, "Android") && strings.Contains(userAgent, "Mobile")):
return "Smartphone"
case strings.Contains(userAgent, "iPad") || strings.Contains(userAgent, "Tablet"):
return "Tablet"
diff --git a/backend/user_settings.go b/backend/user_settings.go
index 7488aef..eee4b28 100644
--- a/backend/user_settings.go
+++ b/backend/user_settings.go
@@ -13,7 +13,7 @@ import (
"golang.org/x/crypto/bcrypt"
)
-type updateSettingsProfileRequest struct {
+type updateSettingsAccountRequest struct {
DisplayName string `json:"displayName"`
Username string `json:"username"`
Email string `json:"email"`
@@ -32,14 +32,14 @@ type confirmPasswordRequest struct {
Password string `json:"password"`
}
-func (s *Server) handleUpdateSettingsProfile(w http.ResponseWriter, r *http.Request) {
+func (s *Server) handleUpdateSettingsAccount(w http.ResponseWriter, r *http.Request) {
userID, ok := s.currentUserIDFromRequest(r)
if !ok {
writeSettingsError(w, http.StatusUnauthorized, "Nicht angemeldet")
return
}
- var payload updateSettingsProfileRequest
+ var payload updateSettingsAccountRequest
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
writeSettingsError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index a83d4e4..84e497b 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -16,6 +16,7 @@ import LoadingSpinner from './components/LoadingSpinner'
import NotificationCenter from './components/NotificationCenter'
import type { Notification, User } from './components/types'
import AdministrationPage from './pages/administration/AdministrationPage'
+import Feedback from './components/Feedback'
import { hasRight } from './components/permissions'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
@@ -75,20 +76,18 @@ function App() {
withCredentials: true,
})
- eventSource.addEventListener('notification', (event) => {
- const notification = parseNotificationEvent(event)
-
- if (!notification) {
+ function addVisibleNotification(notification: Notification) {
+ if (notification.visible === false) {
return
}
- if (notification.visible !== false) {
- setNotifications((currentNotifications) => [
- notification,
- ...currentNotifications.filter((item) => item.id !== notification.id),
- ])
- }
+ setNotifications((currentNotifications) => [
+ notification,
+ ...currentNotifications.filter((item) => item.id !== notification.id),
+ ])
+ }
+ function dispatchEntityEvent(notification: Notification) {
if (notification.entityType === 'operation') {
window.dispatchEvent(
new CustomEvent('operation-notification', {
@@ -104,15 +103,28 @@ function App() {
}),
)
}
- })
+ }
- eventSource.addEventListener('operation-event', (event) => {
+ function handleNotificationEvent(event: MessageEvent) {
const notification = parseNotificationEvent(event)
if (!notification) {
return
}
+ addVisibleNotification(notification)
+ dispatchEntityEvent(notification)
+ }
+
+ function handleOperationEvent(event: MessageEvent) {
+ const notification = parseNotificationEvent(event)
+
+ if (!notification) {
+ return
+ }
+
+ addVisibleNotification(notification)
+
if (
notification.type === 'operation.journal' &&
notification.entityType === 'operation' &&
@@ -125,30 +137,28 @@ function App() {
})
}
- if (notification.entityType === 'operation') {
- window.dispatchEvent(
- new CustomEvent('operation-notification', {
- detail: notification,
- }),
- )
- }
- })
+ dispatchEntityEvent(notification)
+ }
- eventSource.addEventListener('device-event', (event) => {
+ function handleDeviceEvent(event: MessageEvent) {
const notification = parseNotificationEvent(event)
if (!notification) {
return
}
- window.dispatchEvent(
- new CustomEvent('device-notification', {
- detail: notification,
- }),
- )
- })
+ addVisibleNotification(notification)
+ dispatchEntityEvent(notification)
+ }
+
+ eventSource.addEventListener('notification', handleNotificationEvent)
+ eventSource.addEventListener('operation-event', handleOperationEvent)
+ eventSource.addEventListener('device-event', handleDeviceEvent)
return () => {
+ eventSource.removeEventListener('notification', handleNotificationEvent)
+ eventSource.removeEventListener('operation-event', handleOperationEvent)
+ eventSource.removeEventListener('device-event', handleDeviceEvent)
eventSource.close()
}
}, [user])
@@ -354,6 +364,11 @@ function App() {
}
/>
+ }
+ />
+
+ } />
+
} />
diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx
index c34c066..b79a13c 100644
--- a/frontend/src/components/AppLayout.tsx
+++ b/frontend/src/components/AppLayout.tsx
@@ -1,6 +1,7 @@
// frontend/src/components/AppLayout.tsx
-import { useState, type ReactNode } from 'react'
+import { useEffect, useState, type ReactNode } from 'react'
+import { useLocation } from 'react-router'
import { Transition } from '@headlessui/react'
import SearchOverlay from './SearchOverlay'
import Sidebar from './Sidebar'
@@ -20,6 +21,8 @@ export default function AppLayout({
children,
notificationCenter,
}: AppLayoutProps) {
+ const location = useLocation()
+
const [sidebarOpen, setSidebarOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
@@ -29,15 +32,25 @@ export default function AppLayout({
setSearchQuery('')
}
+ function handleNavigation() {
+ setSearchQuery('')
+ setSidebarOpen(false)
+ }
+
+ useEffect(() => {
+ setSearchQuery('')
+ }, [location.pathname, location.search, location.hash])
+
return (
-
+
-
+
-
+
{children}
, 'size'> & {
+ src?: string
+ name?: string
+ initials?: string
+ size?: AvatarSize
+ shape?: AvatarShape
+ status?: AvatarStatus
+ statusPosition?: AvatarStatusPosition
+ showPlaceholderIcon?: boolean
+ wrapperClassName?: string
+}
+
+function classNames(...classes: Array) {
+ return classes.filter(Boolean).join(' ')
+}
+
+const sizeClassNames: Record = {
+ 6: 'size-6',
+ 8: 'size-8',
+ 9: 'size-9',
+ 10: 'size-10',
+ 12: 'size-12',
+ 14: 'size-14',
+ 16: 'size-16',
+}
+
+const initialsSizeClassNames: Record = {
+ 6: 'text-xs',
+ 8: 'text-sm',
+ 9: 'text-sm',
+ 10: 'text-base',
+ 12: 'text-lg',
+ 14: 'text-xl',
+ 16: 'text-2xl',
+}
+
+const statusSizeClassNames: Record = {
+ 6: 'size-1.5',
+ 8: 'size-2',
+ 9: 'size-2',
+ 10: 'size-2.5',
+ 12: 'size-3',
+ 14: 'size-3.5',
+ 16: 'size-4',
+}
+
+const statusColorClassNames: Record = {
+ gray: 'bg-gray-300 dark:bg-gray-500',
+ red: 'bg-red-400 dark:bg-red-500',
+ green: 'bg-green-400 dark:bg-green-500',
+}
+
+function getInitials(name?: string, initials?: string) {
+ if (initials) {
+ return initials.slice(0, 2).toUpperCase()
+ }
+
+ if (!name) {
+ return ''
+ }
+
+ return name
+ .split(/\s+/)
+ .filter(Boolean)
+ .slice(0, 2)
+ .map((part) => part.charAt(0))
+ .join('')
+ .toUpperCase()
+}
+
+function PlaceholderIcon({ className }: { className?: string }) {
+ return (
+
+
+
+ )
+}
+
+export default function Avatar({
+ src,
+ alt,
+ name,
+ initials,
+ size = 10,
+ shape = 'circle',
+ status,
+ statusPosition = 'bottom',
+ showPlaceholderIcon = true,
+ wrapperClassName,
+ className,
+ ...props
+}: AvatarProps) {
+ const roundedClassName = shape === 'circle' ? 'rounded-full' : 'rounded-md'
+ const avatarInitials = getInitials(name, initials)
+
+ const avatar = src ? (
+
+ ) : avatarInitials ? (
+
+
+ {avatarInitials}
+
+
+ ) : (
+
+ {showPlaceholderIcon && (
+
+ )}
+
+ )
+
+ if (!status) {
+ return avatar
+ }
+
+ return (
+
+ {avatar}
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/components/Badge.tsx b/frontend/src/components/Badge.tsx
new file mode 100644
index 0000000..5f5ca82
--- /dev/null
+++ b/frontend/src/components/Badge.tsx
@@ -0,0 +1,198 @@
+// frontend/src/components/Badge.tsx
+
+import type { HTMLAttributes, ReactNode } from "react";
+
+export type BadgeTone =
+ | "gray"
+ | "red"
+ | "yellow"
+ | "green"
+ | "blue"
+ | "indigo"
+ | "purple"
+ | "pink";
+
+export type BadgeVariant = "border" | "flat";
+export type BadgeShape = "rounded" | "pill";
+export type BadgeSize = "default" | "small";
+
+type BadgeProps = Omit, "color"> & {
+ children: ReactNode;
+ tone?: BadgeTone;
+ variant?: BadgeVariant;
+ shape?: BadgeShape;
+ size?: BadgeSize;
+ dot?: boolean;
+ leadingIcon?: ReactNode;
+ trailingIcon?: ReactNode;
+ onRemove?: () => void;
+ removeLabel?: string;
+};
+
+function cn(...classes: Array) {
+ return classes.filter(Boolean).join(" ");
+}
+
+const badgeClasses: Record> = {
+ border: {
+ gray: "bg-gray-50 text-gray-600 inset-ring inset-ring-gray-500/10 dark:bg-gray-400/10 dark:text-gray-400 dark:inset-ring-gray-400/20",
+ red: "bg-red-50 text-red-700 inset-ring inset-ring-red-600/10 dark:bg-red-400/10 dark:text-red-400 dark:inset-ring-red-400/20",
+ yellow:
+ "bg-yellow-50 text-yellow-800 inset-ring inset-ring-yellow-600/20 dark:bg-yellow-400/10 dark:text-yellow-500 dark:inset-ring-yellow-400/20",
+ green:
+ "bg-green-50 text-green-700 inset-ring inset-ring-green-600/20 dark:bg-green-400/10 dark:text-green-400 dark:inset-ring-green-500/20",
+ blue: "bg-blue-50 text-blue-700 inset-ring inset-ring-blue-700/10 dark:bg-blue-400/10 dark:text-blue-400 dark:inset-ring-blue-400/30",
+ indigo:
+ "bg-indigo-50 text-indigo-700 inset-ring inset-ring-indigo-700/10 dark:bg-indigo-400/10 dark:text-indigo-400 dark:inset-ring-indigo-400/30",
+ purple:
+ "bg-purple-50 text-purple-700 inset-ring inset-ring-purple-700/10 dark:bg-purple-400/10 dark:text-purple-400 dark:inset-ring-purple-400/30",
+ pink: "bg-pink-50 text-pink-700 inset-ring inset-ring-pink-700/10 dark:bg-pink-400/10 dark:text-pink-400 dark:inset-ring-pink-400/20",
+ },
+ flat: {
+ gray: "bg-gray-100 text-gray-600 dark:bg-gray-400/10 dark:text-gray-400",
+ red: "bg-red-100 text-red-700 dark:bg-red-400/10 dark:text-red-400",
+ yellow:
+ "bg-yellow-100 text-yellow-800 dark:bg-yellow-400/10 dark:text-yellow-500",
+ green:
+ "bg-green-100 text-green-700 dark:bg-green-400/10 dark:text-green-400",
+ blue: "bg-blue-100 text-blue-700 dark:bg-blue-400/10 dark:text-blue-400",
+ indigo:
+ "bg-indigo-100 text-indigo-700 dark:bg-indigo-400/10 dark:text-indigo-400",
+ purple:
+ "bg-purple-100 text-purple-700 dark:bg-purple-400/10 dark:text-purple-400",
+ pink: "bg-pink-100 text-pink-700 dark:bg-pink-400/10 dark:text-pink-400",
+ },
+};
+
+const dotClasses: Record = {
+ gray: "fill-gray-400",
+ red: "fill-red-500 dark:fill-red-400",
+ yellow: "fill-yellow-500 dark:fill-yellow-400",
+ green: "fill-green-500 dark:fill-green-400",
+ blue: "fill-blue-500 dark:fill-blue-400",
+ indigo: "fill-indigo-500 dark:fill-indigo-400",
+ purple: "fill-purple-500 dark:fill-purple-400",
+ pink: "fill-pink-500 dark:fill-pink-400",
+};
+
+const removeButtonClasses: Record = {
+ gray: "hover:bg-gray-500/20 dark:hover:bg-gray-500/30",
+ red: "hover:bg-red-600/20 dark:hover:bg-red-500/30",
+ yellow: "hover:bg-yellow-600/20 dark:hover:bg-yellow-500/30",
+ green: "hover:bg-green-600/20 dark:hover:bg-green-500/30",
+ blue: "hover:bg-blue-600/20 dark:hover:bg-blue-500/30",
+ indigo: "hover:bg-indigo-600/20 dark:hover:bg-indigo-500/30",
+ purple: "hover:bg-purple-600/20 dark:hover:bg-purple-500/30",
+ pink: "hover:bg-pink-600/20 dark:hover:bg-pink-500/30",
+};
+
+const removeIconClasses: Record = {
+ gray: "stroke-gray-600/50 group-hover:stroke-gray-600/75 dark:stroke-gray-400 dark:group-hover:stroke-gray-300",
+ red: "stroke-red-600/50 group-hover:stroke-red-600/75 dark:stroke-red-400 dark:group-hover:stroke-red-300",
+ yellow:
+ "stroke-yellow-700/50 group-hover:stroke-yellow-700/75 dark:stroke-yellow-400 dark:group-hover:stroke-yellow-300",
+ green:
+ "stroke-green-700/50 group-hover:stroke-green-700/75 dark:stroke-green-400 dark:group-hover:stroke-green-300",
+ blue: "stroke-blue-700/50 group-hover:stroke-blue-700/75 dark:stroke-blue-400 dark:group-hover:stroke-blue-300",
+ indigo:
+ "stroke-indigo-600/50 group-hover:stroke-indigo-600/75 dark:stroke-indigo-400 dark:group-hover:stroke-indigo-300",
+ purple:
+ "stroke-purple-700/50 group-hover:stroke-purple-700/75 dark:stroke-purple-400 dark:group-hover:stroke-purple-300",
+ pink: "stroke-pink-700/50 group-hover:stroke-pink-700/75 dark:stroke-pink-400 dark:group-hover:stroke-pink-300",
+};
+
+function BadgeIcon({
+ children,
+ size,
+}: {
+ children: ReactNode;
+ size: BadgeSize;
+}) {
+ return (
+ svg]:size-3"
+ : "size-3.5 [&>svg]:size-3.5",
+ )}
+ >
+ {children}
+
+ );
+}
+
+export function Badge({
+ children,
+ tone = "gray",
+ variant = "border",
+ shape = "rounded",
+ size = "default",
+ dot = false,
+ leadingIcon,
+ trailingIcon,
+ onRemove,
+ removeLabel = "Remove",
+ className,
+ ...props
+}: BadgeProps) {
+ const hasAdornment = Boolean(dot || leadingIcon || trailingIcon);
+ const hasOnlyRemoveAction = Boolean(onRemove && !hasAdornment);
+
+ return (
+
+ {leadingIcon ? (
+ {leadingIcon}
+ ) : null}
+
+ {dot ? (
+
+
+
+ ) : null}
+
+ {children}
+
+ {trailingIcon ? (
+ {trailingIcon}
+ ) : null}
+
+ {onRemove ? (
+
+ {removeLabel}
+
+
+
+
+
+ ) : null}
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/Checkbox.tsx b/frontend/src/components/Checkbox.tsx
index 4ffa959..ce1b265 100644
--- a/frontend/src/components/Checkbox.tsx
+++ b/frontend/src/components/Checkbox.tsx
@@ -120,10 +120,9 @@ const Checkbox = forwardRef(function Checkbox(
)
const text = label || description ? (
-
+
{label && (
- (function Checkbox(
)}
>
{label}
-
+
)}
{description && (
-
{description}
-
+
)}
-
+
) : null
return (
- (function Checkbox(
{checkbox}
>
)}
-
+
)
})
diff --git a/frontend/src/components/EmptyState.tsx b/frontend/src/components/EmptyState.tsx
new file mode 100644
index 0000000..481d893
--- /dev/null
+++ b/frontend/src/components/EmptyState.tsx
@@ -0,0 +1,100 @@
+// frontend/src/components/EmptyState.tsx
+
+import type { ComponentType, ReactNode } from 'react'
+import { PlusIcon } from '@heroicons/react/20/solid'
+import Button from './Button'
+
+type EmptyStateProps = {
+ title: ReactNode
+ description?: ReactNode
+ icon?: ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
+ actionLabel?: string
+ onAction?: () => void
+ actionIcon?: ReactNode
+ action?: ReactNode
+ className?: string
+ iconClassName?: string
+}
+
+function DefaultIcon({
+ className,
+ 'aria-hidden': ariaHidden,
+}: {
+ className?: string
+ 'aria-hidden'?: boolean
+}) {
+ return (
+
+
+
+ )
+}
+
+function classNames(...classes: Array) {
+ return classes.filter(Boolean).join(' ')
+}
+
+export default function EmptyState({
+ title,
+ description,
+ icon: Icon = DefaultIcon,
+ actionLabel,
+ onAction,
+ actionIcon,
+ action,
+ className,
+ iconClassName,
+}: EmptyStateProps) {
+ return (
+
+
+
+
+ {title}
+
+
+ {description && (
+
+ {description}
+
+ )}
+
+ {(action || (actionLabel && onAction)) && (
+
+ {action ?? (
+
+ )
+ }
+ >
+ {actionLabel}
+
+ )}
+
+ )}
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/components/Feedback.tsx b/frontend/src/components/Feedback.tsx
new file mode 100644
index 0000000..42054b0
--- /dev/null
+++ b/frontend/src/components/Feedback.tsx
@@ -0,0 +1,111 @@
+// frontend/src/pages/Feedback.tsx
+
+import { useState, type FormEvent } from 'react'
+import Textarea from '../components/Textarea'
+
+const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
+
+export default function Feedback() {
+ const [message, setMessage] = useState('')
+ const [isSubmitting, setIsSubmitting] = useState(false)
+ const [error, setError] = useState(null)
+ const [successMessage, setSuccessMessage] = useState(null)
+
+ async function handleSubmit(event: FormEvent) {
+ event.preventDefault()
+
+ const trimmedMessage = message.trim()
+
+ if (!trimmedMessage) {
+ setError('Bitte gib dein Feedback ein.')
+ setSuccessMessage(null)
+ return
+ }
+
+ setIsSubmitting(true)
+ setError(null)
+ setSuccessMessage(null)
+
+ try {
+ const response = await fetch(`${API_URL}/feedback`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ credentials: 'include',
+ body: JSON.stringify({
+ message: trimmedMessage,
+ }),
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => null)
+ throw new Error(errorData?.error ?? 'Feedback konnte nicht gesendet werden')
+ }
+
+ setMessage('')
+ setSuccessMessage('Danke, dein Feedback wurde gespeichert.')
+ } catch (error) {
+ setError(
+ error instanceof Error
+ ? error.message
+ : 'Feedback konnte nicht gesendet werden',
+ )
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ return (
+
+
+
+
+ Feedback
+
+
+
+ Teile uns mit, wenn dir etwas auffällt, etwas nicht funktioniert oder du eine Idee zur Verbesserung hast.
+ Beim Absenden wird automatisch ein technisches Log mit deinem Benutzerkonto angehängt.
+
+
+
+ {successMessage && (
+
+ {successMessage}
+
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/components/Map.tsx b/frontend/src/components/Map.tsx
index 23d5066..bb428eb 100644
--- a/frontend/src/components/Map.tsx
+++ b/frontend/src/components/Map.tsx
@@ -2,6 +2,7 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
import {
+ LayerGroup,
LayersControl,
MapContainer,
Marker,
@@ -10,6 +11,7 @@ import {
Tooltip,
WMSTileLayer,
useMap,
+ useMapEvents,
} from 'react-leaflet'
import * as L from 'leaflet'
import 'leaflet.heat'
@@ -72,6 +74,8 @@ type MapProps = {
heatPoints?: MapHeatPoint[]
showHeatmap?: boolean
heatmapOptions?: MapHeatOptions
+ heatmapLayerName?: string
+ heatmapChecked?: boolean
center?: [number, number]
zoom?: number
@@ -239,14 +243,16 @@ type LeafletWithHeat = typeof L & {
function HeatmapLayer({
points,
options,
+ enabled = true,
}: {
points: MapHeatPoint[]
options?: MapHeatOptions
+ enabled?: boolean
}) {
const map = useMap()
useEffect(() => {
- if (points.length === 0) {
+ if (!enabled || points.length === 0) {
return
}
@@ -264,7 +270,34 @@ function HeatmapLayer({
return () => {
heatLayer.remove()
}
- }, [map, points, options])
+ }, [enabled, map, points, options])
+
+ return null
+}
+
+function HeatmapLayerControlEvents({
+ heatmapLayerName,
+ onVisibilityChange,
+}: {
+ heatmapLayerName: string
+ onVisibilityChange: (visible: boolean) => void
+}) {
+ useMapEvents({
+ overlayadd: (event) => {
+ const layerEvent = event as L.LeafletEvent & { name?: string }
+
+ if (layerEvent.name === heatmapLayerName) {
+ onVisibilityChange(true)
+ }
+ },
+ overlayremove: (event) => {
+ const layerEvent = event as L.LeafletEvent & { name?: string }
+
+ if (layerEvent.name === heatmapLayerName) {
+ onVisibilityChange(false)
+ }
+ },
+ })
return null
}
@@ -482,6 +515,8 @@ export default function LeafletMap({
heatPoints = [],
showHeatmap = false,
heatmapOptions,
+ heatmapLayerName = 'Heatmap',
+ heatmapChecked = false,
center = [51.1657, 10.4515],
zoom = 6,
@@ -506,6 +541,7 @@ export default function LeafletMap({
}: MapProps) {
const [currentLocation, setCurrentLocation] = useState<[number, number] | null>(null)
+ const [isHeatmapLayerVisible, setIsHeatmapLayerVisible] = useState(heatmapChecked)
const fallbackTileLayer =
tileLayers.find((layer) => layer.checked) ?? tileLayers[0]
@@ -519,6 +555,10 @@ export default function LeafletMap({
? heatPoints
: markers
+ useEffect(() => {
+ setIsHeatmapLayerVisible(heatmapChecked)
+ }, [heatmapChecked])
+
return (
- {showLayerControl ? (
+
+
+
+ {showLayerControl ? (
{tileLayers.map((layer) => (
))}
+
+ {showHeatmap && heatPoints.length > 0 && (
+
+
+
+ )}
) : fallbackTileLayer ? (
renderTileLayer(fallbackTileLayer, maxZoom)
@@ -572,12 +627,11 @@ export default function LeafletMap({
renderWmsLayer(fallbackWmsLayer, maxZoom)
) : null}
- {showHeatmap && (
-
- )}
+
{showLocateControl && (
void
}
+type NotificationTab = 'unread' | 'all'
+
function formatNotificationDate(value: string) {
try {
return new Intl.DateTimeFormat('de-DE', {
@@ -29,9 +37,78 @@ export default function NotificationCenter({
onMarkAllRead,
}: NotificationCenterProps) {
const [open, setOpen] = useState(false)
+ const [activeTab, setActiveTab] = useState('unread')
+ const containerRef = useRef(null)
+
+ const filteredNotifications = useMemo(() => {
+ if (activeTab === 'unread') {
+ return notifications.filter((notification) => !notification.readAt)
+ }
+
+ return notifications
+ }, [activeTab, notifications])
+
+ const notificationTabs: TabItem[] = [
+ {
+ name: 'Ungelesen',
+ href: '#',
+ current: activeTab === 'unread',
+ count: unreadCount > 0 ? unreadCount : undefined,
+ onClick: () => setActiveTab('unread'),
+ },
+ {
+ name: 'Alle',
+ href: '#',
+ current: activeTab === 'all',
+ count: notifications.length > 0 ? notifications.length : undefined,
+ onClick: () => setActiveTab('all'),
+ },
+ ]
+
+ useEffect(() => {
+ if (!open) {
+ return
+ }
+
+ function handlePointerDown(event: PointerEvent) {
+ const target = event.target
+
+ if (!(target instanceof Node)) {
+ return
+ }
+
+ if (containerRef.current?.contains(target)) {
+ return
+ }
+
+ setOpen(false)
+ }
+
+ function handleKeyDown(event: KeyboardEvent) {
+ if (event.key === 'Escape') {
+ setOpen(false)
+ }
+ }
+
+ document.addEventListener('pointerdown', handlePointerDown)
+ document.addEventListener('keydown', handleKeyDown)
+
+ return () => {
+ document.removeEventListener('pointerdown', handlePointerDown)
+ document.removeEventListener('keydown', handleKeyDown)
+ }
+ }, [open])
+
+ function getEmptyText() {
+ if (activeTab === 'unread') {
+ return 'Keine ungelesenen Benachrichtigungen vorhanden.'
+ }
+
+ return 'Keine Benachrichtigungen vorhanden.'
+ }
return (
-
+
setOpen((value) => !value)}
@@ -49,29 +126,57 @@ export default function NotificationCenter({
{open && (
-
-
+
+
Benachrichtigungen
- {unreadCount > 0 && (
-
- Alle gelesen
-
- )}
+
+ {unreadCount > 0 && (
+
+ }
+ >
+ Alle gelesen
+
+ )}
+
-
- {notifications.length === 0 ? (
-
- Keine Benachrichtigungen vorhanden.
-
+
+
+
+ {filteredNotifications.length === 0 ? (
+
+
+
+
+ Alles erledigt
+
+
+
+ {getEmptyText()}
+
+
) : (
- notifications.map((notification) => {
+ filteredNotifications.map((notification) => {
const unread = !notification.readAt
return (
diff --git a/frontend/src/components/SearchOverlay.tsx b/frontend/src/components/SearchOverlay.tsx
index 97eab18..884739d 100644
--- a/frontend/src/components/SearchOverlay.tsx
+++ b/frontend/src/components/SearchOverlay.tsx
@@ -3,6 +3,8 @@
import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router'
import {
+ ArrowRightIcon,
+ ChatBubbleLeftEllipsisIcon,
CircleStackIcon,
ClipboardDocumentListIcon,
ExclamationTriangleIcon,
@@ -10,6 +12,7 @@ import {
UserGroupIcon,
} from '@heroicons/react/24/outline'
import type { User } from './types'
+import Checkbox from './Checkbox'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
@@ -48,8 +51,12 @@ function getGroupIcon(groupId: string) {
switch (groupId) {
case 'operations':
return ClipboardDocumentListIcon
+ case 'operation-journals':
+ return ChatBubbleLeftEllipsisIcon
case 'devices':
return CircleStackIcon
+ case 'device-journals':
+ return ChatBubbleLeftEllipsisIcon
case 'users':
return UserGroupIcon
default:
@@ -57,25 +64,91 @@ function getGroupIcon(groupId: string) {
}
}
-function getEntityLabel(entityType: string) {
- switch (entityType) {
- case 'operation':
- return 'Einsatz'
- case 'device':
- return 'Gerät'
- case 'user':
- return 'Benutzer'
- default:
- return entityType
- }
-}
-
async function readApiError(response: Response, fallback: string) {
const data = await response.json().catch(() => null)
return data?.error ?? fallback
}
+function escapeRegExp(value: string) {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+}
+
+function getHighlightTokens(
+ search: string,
+ exactPhrase: boolean,
+ caseSensitive: boolean,
+) {
+ const trimmedSearch = search.trim()
+
+ if (exactPhrase && /\s/.test(trimmedSearch)) {
+ return [
+ caseSensitive
+ ? trimmedSearch
+ : trimmedSearch.toLowerCase(),
+ ]
+ }
+
+ const normalizedTokens = new Set
()
+
+ trimmedSearch
+ .split(/\s+/)
+ .map((token) => token.trim())
+ .filter((token) => token.length >= 2)
+ .forEach((token) =>
+ normalizedTokens.add(caseSensitive ? token : token.toLowerCase()),
+ )
+
+ return Array.from(normalizedTokens)
+}
+
+function HighlightedText({
+ value,
+ search,
+ exactPhrase,
+ caseSensitive,
+}: {
+ value: string
+ search: string
+ exactPhrase: boolean
+ caseSensitive: boolean
+}) {
+ const tokens = getHighlightTokens(search, exactPhrase, caseSensitive)
+
+ if (tokens.length === 0) {
+ return <>{value}>
+ }
+
+ const pattern = new RegExp(
+ `(${tokens.map(escapeRegExp).join('|')})`,
+ caseSensitive ? 'g' : 'ig',
+ )
+
+ const parts = value.split(pattern)
+
+ return (
+ <>
+ {parts.map((part, index) => {
+ const normalizedPart = caseSensitive ? part : part.toLowerCase()
+ const isMatch = tokens.includes(normalizedPart)
+
+ if (!isMatch) {
+ return {part}
+ }
+
+ return (
+
+ {part}
+
+ )
+ })}
+ >
+ )
+}
+
export default function SearchOverlay({
user,
query,
@@ -89,6 +162,18 @@ export default function SearchOverlay({
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState(null)
+ const [exactPhrase, setExactPhrase] = useState(false)
+ const [caseSensitive, setCaseSensitive] = useState(false)
+
+ const canUseExactPhrase = /\s/.test(search)
+ const effectiveExactPhrase = canUseExactPhrase && exactPhrase
+
+ useEffect(() => {
+ if (!canUseExactPhrase && exactPhrase) {
+ setExactPhrase(false)
+ }
+ }, [canUseExactPhrase, exactPhrase])
+
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
@@ -118,8 +203,21 @@ export default function SearchOverlay({
setError(null)
try {
+ const searchParams = new URLSearchParams({
+ q: search,
+ limit: '8',
+ })
+
+ if (effectiveExactPhrase) {
+ searchParams.set('exact', '1')
+ }
+
+ if (caseSensitive) {
+ searchParams.set('caseSensitive', '1')
+ }
+
const response = await fetch(
- `${API_URL}/search?q=${encodeURIComponent(search)}&limit=8`,
+ `${API_URL}/search?${searchParams.toString()}`,
{
credentials: 'include',
signal: abortController.signal,
@@ -137,11 +235,13 @@ export default function SearchOverlay({
const data = (await response.json()) as GlobalSearchResponse
- const nextGroups: SearchGroup[] = (data.groups ?? []).map((group) => ({
- ...group,
- icon: getGroupIcon(group.id),
- items: group.items ?? [],
- }))
+ const nextGroups: SearchGroup[] = (data.groups ?? [])
+ .map((group) => ({
+ ...group,
+ icon: getGroupIcon(group.id),
+ items: group.items ?? [],
+ }))
+ .filter((group) => group.items.length > 0)
setGroups(nextGroups)
} catch (error) {
@@ -170,41 +270,88 @@ export default function SearchOverlay({
window.clearTimeout(timeoutId)
abortController.abort()
}
- }, [search])
+ }, [search, effectiveExactPhrase, caseSensitive])
const resultCount = useMemo(
() => groups.reduce((count, group) => count + group.items.length, 0),
[groups],
)
+ const hasResults = resultCount > 0
+ const showFullLoading = search.length >= 2 && isLoading && !hasResults
+ const showError = search.length >= 2 && !isLoading && error
+ const showNoResults =
+ search.length >= 2 && !isLoading && !error && !hasResults
+
return (
-
-
-
-
-
-
+
+
+ {/* Header */}
+
+
+
+
+
+
+
+
+
+ Suche
+
+
+
+ {search.length >= 2
+ ? `${resultCount} Treffer für „${query}“`
+ : 'Gib mindestens 2 Zeichen ein.'}
+
+
-
-
- Suche
-
+
+ {search.length >= 2 && !error && (
+
+ {isLoading && (
+
+ )}
-
- {search.length >= 2
- ? `${resultCount} Treffer für „${query}“`
- : 'Gib mindestens 2 Zeichen ein.'}
-
+ {isLoading ? 'Suche läuft...' : `${groups.length} Kategorien`}
+
+ )}
+
+
+ setExactPhrase(event.target.checked)}
+ label="Exakter Wortlaut"
+ wrapperClassName={[
+ 'items-center gap-2 rounded-full px-3 py-1.5 ring-1 ring-inset',
+ canUseExactPhrase
+ ? 'cursor-pointer bg-white text-gray-700 ring-gray-200 hover:bg-gray-50'
+ : 'cursor-not-allowed bg-gray-50 text-gray-400 ring-gray-200',
+ ].join(' ')}
+ labelClassName="text-xs/5 font-medium text-inherit"
+ inputClassName="size-4"
+ />
+
+ setCaseSensitive(event.target.checked)}
+ label="Groß-/Kleinschreibung"
+ wrapperClassName="items-center gap-2 rounded-full bg-white px-3 py-1.5 text-gray-700 ring-1 ring-gray-200 ring-inset hover:bg-gray-50"
+ labelClassName="text-xs/5 font-medium text-inherit"
+ inputClassName="size-4"
+ />
+
{search.length < 2 && (
-
+
)}
- {search.length >= 2 && isLoading && (
-
+ {showFullLoading && (
+
@@ -234,8 +381,8 @@ export default function SearchOverlay({
)}
- {search.length >= 2 && !isLoading && error && (
-
+ {showError && (
+
)}
- {search.length >= 2 && !isLoading && !error && resultCount === 0 && (
-
+ {showNoResults && (
+
)}
- {!isLoading && !error && groups.length > 0 && (
-
+ {!error && groups.length > 0 && (
+
{groups.map((group) => {
const Icon = group.icon
return (
-