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 ? ( + {alt + ) : 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 ( + + ); +} + +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 ? ( + + ) : 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 && ( - + )} {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 ?? ( + + )} +
+ )} +
+ ) +} \ 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} +
+ )} + +
+