// 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 }