// backend\devices.go package main import ( "context" "errors" "net/http" "strings" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" ) 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"` IPAddress string `json:"ipAddress"` PhoneNumber string `json:"phoneNumber"` ComputerName string `json:"computerName"` DeviceCategory string `json:"deviceCategory"` MilestonePort string `json:"milestonePort"` Location string `json:"location"` LoanStatus string `json:"loanStatus"` LoanedToUserID string `json:"loanedToUserId"` LoanedToDisplayName string `json:"loanedToDisplayName"` LoanedUntil string `json:"loanedUntil"` 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"` MilestoneChildDevices []MilestoneHardwareChildDevice `json:"milestoneChildDevices"` 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 { ID string `json:"id"` InventoryNumber string `json:"inventoryNumber"` Manufacturer string `json:"manufacturer"` Model string `json:"model"` } type DeviceLoanUser struct { ID string `json:"id"` Username string `json:"username"` DisplayName string `json:"displayName"` Email string `json:"email"` Avatar string `json:"avatar"` Unit string `json:"unit"` } type MilestoneHardwareChildDevice struct { ID string `json:"id"` MilestoneHardwareID string `json:"milestoneHardwareId"` MilestoneDeviceID string `json:"milestoneDeviceId"` DeviceType string `json:"deviceType"` Name string `json:"name"` DisplayName string `json:"displayName"` ShortName string `json:"shortName"` Enabled bool `json:"enabled"` RecordingEnabled bool `json:"recordingEnabled"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } type CreateDeviceRequest struct { InventoryNumber string `json:"inventoryNumber"` Manufacturer string `json:"manufacturer"` Model string `json:"model"` SerialNumber string `json:"serialNumber"` MacAddress string `json:"macAddress"` IPAddress string `json:"ipAddress"` PhoneNumber string `json:"phoneNumber"` ComputerName string `json:"computerName"` DeviceCategory string `json:"deviceCategory"` MilestonePort string `json:"milestonePort"` FirmwareVersion string `json:"firmwareVersion"` MilestoneDisplayName string `json:"milestoneDisplayName"` MilestoneEnabled *bool `json:"milestoneEnabled,omitempty"` Location string `json:"location"` LoanStatus string `json:"loanStatus"` LoanedToUserID string `json:"loanedToUserId"` LoanedUntil string `json:"loanedUntil"` IsBaoDevice bool `json:"isBaoDevice"` Comment string `json:"comment"` RelatedDeviceIDs []string `json:"relatedDeviceIds"` } type UpdateDeviceRequest = CreateDeviceRequest type UpdateDeviceJournalEntryRequest struct { Content string `json:"content"` } var errDeviceNotFound = errors.New("device does not exist") func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: s.handleListDevices(w, r) case http.MethodPost: s.handleCreateDevice(w, r) default: writeError(w, http.StatusMethodNotAllowed, "Method not allowed") } } func (s *Server) handleListDeviceLoanUsers(w http.ResponseWriter, r *http.Request) { rows, err := s.db.Query( r.Context(), ` SELECT id::TEXT, COALESCE(username, ''), COALESCE(display_name, ''), email, COALESCE(avatar, ''), COALESCE(unit, '') FROM users ORDER BY lower(COALESCE(NULLIF(display_name, ''), username, email)), lower(email) `, ) if err != nil { writeError(w, http.StatusInternalServerError, "Benutzer konnten nicht geladen werden") return } defer rows.Close() users := []DeviceLoanUser{} for rows.Next() { var user DeviceLoanUser if err := rows.Scan( &user.ID, &user.Username, &user.DisplayName, &user.Email, &user.Avatar, &user.Unit, ); err != nil { writeError(w, http.StatusInternalServerError, "Benutzer konnten nicht gelesen werden") return } users = append(users, user) } if err := rows.Err(); err != nil { writeError(w, http.StatusInternalServerError, "Benutzer konnten nicht vollständig geladen werden") return } writeJSON(w, http.StatusOK, map[string]any{"users": users}) } func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) { rows, err := s.db.Query( r.Context(), ` SELECT devices.id, COALESCE(devices.inventory_number, ''), COALESCE(devices.manufacturer, ''), COALESCE(devices.model, ''), COALESCE(devices.serial_number, ''), COALESCE(devices.mac_address, ''), COALESCE(devices.ip_address, ''), COALESCE(devices.phone_number, ''), COALESCE(devices.computer_name, ''), COALESCE(devices.device_category, ''), COALESCE(devices.milestone_port, ''), COALESCE(devices.location, ''), COALESCE(devices.loan_status, 'Verfügbar'), COALESCE(devices.loaned_to_user_id::TEXT, ''), COALESCE(NULLIF(loaned_user.display_name, ''), NULLIF(loaned_user.username, ''), loaned_user.email, ''), COALESCE(devices.loaned_until::TEXT, ''), COALESCE(devices.is_bao_device, false), COALESCE(devices.comment, ''), COALESCE(devices.firmware_version, ''), COALESCE(devices.milestone_recording_server_id, ''), COALESCE(devices.milestone_hardware_id, ''), COALESCE(devices.milestone_display_name, ''), COALESCE(devices.milestone_enabled, true), devices.milestone_synced_at, devices.created_at, devices.updated_at FROM devices LEFT JOIN users AS loaned_user ON loaned_user.id = devices.loaned_to_user_id ORDER BY devices.inventory_number ASC `, ) if err != nil { logError("Geräte konnten nicht geladen werden", err, nil) writeError(w, http.StatusInternalServerError, "Could not load devices") return } defer rows.Close() devices := make([]Device, 0) deviceIndexMap := map[string]int{} for rows.Next() { var device Device if err := rows.Scan( &device.ID, &device.InventoryNumber, &device.Manufacturer, &device.Model, &device.SerialNumber, &device.MacAddress, &device.IPAddress, &device.PhoneNumber, &device.ComputerName, &device.DeviceCategory, &device.MilestonePort, &device.Location, &device.LoanStatus, &device.LoanedToUserID, &device.LoanedToDisplayName, &device.LoanedUntil, &device.IsBaoDevice, &device.Comment, &device.FirmwareVersion, &device.MilestoneRecordingServerID, &device.MilestoneHardwareID, &device.MilestoneDisplayName, &device.MilestoneEnabled, &device.MilestoneSyncedAt, &device.CreatedAt, &device.UpdatedAt, ); err != nil { logError("Geräte konnten nicht gelesen werden", err, nil) writeError(w, http.StatusInternalServerError, "Could not read devices") return } device.RelatedDevices = []DeviceShort{} device.MilestoneChildDevices = []MilestoneHardwareChildDevice{} devices = append(devices, device) deviceIndexMap[device.ID] = len(devices) - 1 } if err := rows.Err(); err != nil { logError("Geräte konnten nicht vollständig gelesen werden", err, nil) writeError(w, http.StatusInternalServerError, "Could not read devices") return } relationRows, err := s.db.Query( r.Context(), ` SELECT device_relations.device_id, related_devices.id, COALESCE(related_devices.inventory_number, ''), COALESCE(related_devices.manufacturer, ''), COALESCE(related_devices.model, '') FROM device_relations INNER JOIN devices AS related_devices ON related_devices.id = device_relations.related_device_id ORDER BY related_devices.inventory_number ASC `, ) if err != nil { logError("Geräte-Zuordnungen konnten nicht geladen werden", err, nil) writeError(w, http.StatusInternalServerError, "Could not load device relations") return } defer relationRows.Close() for relationRows.Next() { var deviceID string var relatedDevice DeviceShort if err := relationRows.Scan( &deviceID, &relatedDevice.ID, &relatedDevice.InventoryNumber, &relatedDevice.Manufacturer, &relatedDevice.Model, ); err != nil { logError("Geräte-Zuordnungen konnten nicht gelesen werden", err, nil) writeError(w, http.StatusInternalServerError, "Could not read device relations") return } deviceIndex, exists := deviceIndexMap[deviceID] if exists { devices[deviceIndex].RelatedDevices = append( devices[deviceIndex].RelatedDevices, relatedDevice, ) } } if err := relationRows.Err(); err != nil { logError("Geräte-Zuordnungen konnten nicht vollständig gelesen werden", err, nil) writeError(w, http.StatusInternalServerError, "Could not read device relations") return } childRows, err := s.db.Query( r.Context(), ` SELECT devices.id::TEXT, milestone_hardware_child_devices.id::TEXT, COALESCE(milestone_hardware_child_devices.milestone_hardware_id, ''), COALESCE(milestone_hardware_child_devices.milestone_device_id, ''), COALESCE(milestone_hardware_child_devices.device_type, ''), COALESCE(milestone_hardware_child_devices.name, ''), COALESCE(milestone_hardware_child_devices.display_name, ''), COALESCE(milestone_hardware_child_devices.short_name, ''), COALESCE(milestone_hardware_child_devices.enabled, true), COALESCE(milestone_hardware_child_devices.recording_enabled, false), milestone_hardware_child_devices.created_at, milestone_hardware_child_devices.updated_at FROM milestone_hardware_child_devices INNER JOIN devices ON devices.milestone_hardware_id = milestone_hardware_child_devices.milestone_hardware_id WHERE COALESCE(devices.milestone_hardware_id, '') <> '' ORDER BY milestone_hardware_child_devices.device_type ASC, milestone_hardware_child_devices.name ASC, milestone_hardware_child_devices.display_name ASC `, ) if err != nil { logError("Milestone-Child-Devices konnten nicht geladen werden", err, nil) writeError(w, http.StatusInternalServerError, "Could not load Milestone child devices") return } defer childRows.Close() for childRows.Next() { var deviceID string var childDevice MilestoneHardwareChildDevice if err := childRows.Scan( &deviceID, &childDevice.ID, &childDevice.MilestoneHardwareID, &childDevice.MilestoneDeviceID, &childDevice.DeviceType, &childDevice.Name, &childDevice.DisplayName, &childDevice.ShortName, &childDevice.Enabled, &childDevice.RecordingEnabled, &childDevice.CreatedAt, &childDevice.UpdatedAt, ); err != nil { logError("Milestone-Child-Devices konnten nicht gelesen werden", err, nil) writeError(w, http.StatusInternalServerError, "Could not read Milestone child devices") return } deviceIndex, exists := deviceIndexMap[deviceID] if exists { devices[deviceIndex].MilestoneChildDevices = append( devices[deviceIndex].MilestoneChildDevices, childDevice, ) } } if err := childRows.Err(); err != nil { logError("Milestone-Child-Devices konnten nicht vollständig gelesen werden", err, nil) writeError(w, http.StatusInternalServerError, "Could not read Milestone child devices") return } s.enrichDevicesWithFirmwareUpdates(r.Context(), devices) writeJSON(w, http.StatusOK, map[string]any{ "devices": devices, }) } func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) { var input CreateDeviceRequest if err := readJSON(r, &input); err != nil { writeError(w, http.StatusBadRequest, "Invalid JSON") return } normalizeDeviceInput(&input) if input.InventoryNumber == "" { writeError(w, http.StatusBadRequest, "Inventur-Nr. ist erforderlich") return } user, ok := userFromContext(r.Context()) if !ok { writeError(w, http.StatusUnauthorized, "Unauthorized") return } tx, err := s.db.Begin(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "Could not create device") return } defer tx.Rollback(r.Context()) if err := ensureDeviceLookupValues(r.Context(), tx, input.Manufacturer, input.Location); err != nil { writeError(w, http.StatusInternalServerError, "Hersteller oder Standort konnte nicht gespeichert werden") return } var device Device err = tx.QueryRow( r.Context(), ` INSERT INTO devices ( inventory_number, manufacturer, model, serial_number, mac_address, ip_address, phone_number, computer_name, device_category, milestone_port, location, loan_status, loaned_to_user_id, loaned_until, is_bao_device, comment, firmware_version ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NULLIF($13, '')::UUID, NULLIF($14, '')::DATE, $15, $16, $17) RETURNING id, inventory_number, manufacturer, model, serial_number, mac_address, COALESCE(ip_address, ''), COALESCE(phone_number, ''), COALESCE(computer_name, ''), COALESCE(device_category, ''), COALESCE(milestone_port, ''), location, loan_status, COALESCE(loaned_to_user_id::TEXT, ''), '', COALESCE(loaned_until::TEXT, ''), 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 `, input.InventoryNumber, input.Manufacturer, input.Model, input.SerialNumber, input.MacAddress, input.IPAddress, input.PhoneNumber, input.ComputerName, input.DeviceCategory, input.MilestonePort, input.Location, input.LoanStatus, input.LoanedToUserID, input.LoanedUntil, input.IsBaoDevice, input.Comment, input.FirmwareVersion, ).Scan( &device.ID, &device.InventoryNumber, &device.Manufacturer, &device.Model, &device.SerialNumber, &device.MacAddress, &device.IPAddress, &device.PhoneNumber, &device.ComputerName, &device.DeviceCategory, &device.MilestonePort, &device.Location, &device.LoanStatus, &device.LoanedToUserID, &device.LoanedToDisplayName, &device.LoanedUntil, &device.IsBaoDevice, &device.Comment, &device.FirmwareVersion, &device.MilestoneRecordingServerID, &device.MilestoneHardwareID, &device.MilestoneDisplayName, &device.MilestoneEnabled, &device.MilestoneSyncedAt, &device.CreatedAt, &device.UpdatedAt, ) if err != nil { var pgErr *pgconn.PgError if errors.As(err, &pgErr) && pgErr.Code == "23505" { writeError(w, http.StatusConflict, "Gerät mit dieser Inventur-Nr., Seriennummer oder MAC-Adresse existiert bereits") return } writeError(w, http.StatusInternalServerError, "Could not create device") return } if err := syncDeviceRelations(r.Context(), tx, device.ID, input.RelatedDeviceIDs); err != nil { if errors.Is(err, errDeviceNotFound) { writeError(w, http.StatusBadRequest, "Zugeordnetes Gerät wurde nicht gefunden") return } writeError(w, http.StatusInternalServerError, "Could not assign related device") return } changes, err := buildDeviceCreateHistoryChanges(r.Context(), tx, input) if err != nil { writeError(w, http.StatusInternalServerError, "Could not create device history") return } if err := recordDeviceHistory(r.Context(), tx, device.ID, user, "created", changes); err != nil { writeError(w, http.StatusInternalServerError, "Could not create device history") return } if err := tx.Commit(r.Context()); err != nil { writeError(w, http.StatusInternalServerError, "Could not create device") return } 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, }) } func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) { deviceID := strings.TrimSpace(r.PathValue("id")) if deviceID == "" { writeError(w, http.StatusBadRequest, "Geräte-ID fehlt") return } var input UpdateDeviceRequest if err := readJSON(r, &input); err != nil { writeError(w, http.StatusBadRequest, "Invalid JSON") return } normalizeDeviceInput(&input) if input.InventoryNumber == "" { writeError(w, http.StatusBadRequest, "Inventur-Nr. ist erforderlich") return } user, ok := userFromContext(r.Context()) if !ok { writeError(w, http.StatusUnauthorized, "Unauthorized") return } tx, err := s.db.Begin(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "Could not update device") return } defer tx.Rollback(r.Context()) if err := ensureDeviceLookupValues(r.Context(), tx, input.Manufacturer, input.Location); err != nil { writeError(w, http.StatusInternalServerError, "Hersteller oder Standort konnte nicht gespeichert werden") return } oldDevice, oldRelatedDeviceIDs, err := getDeviceForHistory(r.Context(), tx, deviceID) if errors.Is(err, pgx.ErrNoRows) { writeError(w, http.StatusNotFound, "Gerät wurde nicht gefunden") return } if err != nil { logError("Device history state konnte nicht geladen werden", err, LogFields{ "deviceId": deviceID, }) writeError(w, http.StatusInternalServerError, "Could not load device history state") return } nextMilestoneEnabled := oldDevice.MilestoneEnabled if input.MilestoneEnabled != nil { nextMilestoneEnabled = *input.MilestoneEnabled } var device Device err = tx.QueryRow( r.Context(), ` UPDATE devices SET inventory_number = $2, manufacturer = $3, model = $4, serial_number = $5, mac_address = $6, ip_address = $7, phone_number = $8, computer_name = $9, device_category = $10, milestone_port = $11, location = $12, loan_status = $13, loaned_to_user_id = NULLIF($14, '')::UUID, loaned_until = NULLIF($15, '')::DATE, is_bao_device = $16, comment = $17, firmware_version = $18, milestone_display_name = $19, milestone_enabled = $20, milestone_synced_at = CASE WHEN COALESCE(milestone_hardware_id, '') <> '' AND ( COALESCE(milestone_display_name, '') <> $19 OR COALESCE(ip_address, '') <> $7 OR COALESCE(milestone_port, '') <> $11 OR milestone_enabled <> $20 ) THEN now() ELSE milestone_synced_at END, updated_at = now() WHERE id = $1 RETURNING id, inventory_number, manufacturer, model, serial_number, mac_address, COALESCE(ip_address, ''), COALESCE(phone_number, ''), COALESCE(computer_name, ''), COALESCE(device_category, ''), COALESCE(milestone_port, ''), location, loan_status, COALESCE(loaned_to_user_id::TEXT, ''), '', COALESCE(loaned_until::TEXT, ''), 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 `, deviceID, input.InventoryNumber, input.Manufacturer, input.Model, input.SerialNumber, input.MacAddress, input.IPAddress, input.PhoneNumber, input.ComputerName, input.DeviceCategory, input.MilestonePort, input.Location, input.LoanStatus, input.LoanedToUserID, input.LoanedUntil, input.IsBaoDevice, input.Comment, input.FirmwareVersion, input.MilestoneDisplayName, nextMilestoneEnabled, ).Scan( &device.ID, &device.InventoryNumber, &device.Manufacturer, &device.Model, &device.SerialNumber, &device.MacAddress, &device.IPAddress, &device.PhoneNumber, &device.ComputerName, &device.DeviceCategory, &device.MilestonePort, &device.Location, &device.LoanStatus, &device.LoanedToUserID, &device.LoanedToDisplayName, &device.LoanedUntil, &device.IsBaoDevice, &device.Comment, &device.FirmwareVersion, &device.MilestoneRecordingServerID, &device.MilestoneHardwareID, &device.MilestoneDisplayName, &device.MilestoneEnabled, &device.MilestoneSyncedAt, &device.CreatedAt, &device.UpdatedAt, ) if errors.Is(err, pgx.ErrNoRows) { writeError(w, http.StatusNotFound, "Gerät wurde nicht gefunden") return } if err != nil { var pgErr *pgconn.PgError if errors.As(err, &pgErr) && pgErr.Code == "23505" { writeError(w, http.StatusConflict, "Gerät mit dieser Inventur-Nr., Seriennummer oder MAC-Adresse existiert bereits") return } writeError(w, http.StatusInternalServerError, "Could not update device") return } if err := syncDeviceRelations(r.Context(), tx, device.ID, input.RelatedDeviceIDs); err != nil { if errors.Is(err, errDeviceNotFound) { writeError(w, http.StatusBadRequest, "Zugeordnetes Gerät wurde nicht gefunden") return } writeError(w, http.StatusInternalServerError, "Could not update device relations") return } changes, err := buildDeviceUpdateHistoryChanges(r.Context(), tx, oldDevice, oldRelatedDeviceIDs, input) if err != nil { writeError(w, http.StatusInternalServerError, "Could not create device history") return } if len(changes) > 0 { if err := recordDeviceHistory(r.Context(), tx, device.ID, user, "updated", changes); err != nil { writeError(w, http.StatusInternalServerError, "Could not create device history") return } } if err := tx.Commit(r.Context()); err != nil { writeError(w, http.StatusInternalServerError, "Could not update device") return } 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, }) } func (s *Server) handleDeleteDevice(w http.ResponseWriter, r *http.Request) { deviceID := strings.TrimSpace(r.PathValue("id")) if deviceID == "" { writeError(w, http.StatusBadRequest, "Geräte-ID fehlt") return } user, ok := userFromContext(r.Context()) if !ok { writeError(w, http.StatusUnauthorized, "Unauthorized") return } tx, err := s.db.Begin(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "Gerät konnte nicht gelöscht werden") return } defer tx.Rollback(r.Context()) var device Device err = tx.QueryRow( r.Context(), ` SELECT id, COALESCE(inventory_number, ''), COALESCE(manufacturer, ''), COALESCE(model, ''), COALESCE(milestone_recording_server_id, ''), COALESCE(milestone_hardware_id, ''), COALESCE(milestone_display_name, '') FROM devices WHERE id = $1 FOR UPDATE `, deviceID, ).Scan( &device.ID, &device.InventoryNumber, &device.Manufacturer, &device.Model, &device.MilestoneRecordingServerID, &device.MilestoneHardwareID, &device.MilestoneDisplayName, ) 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 gelöscht werden") return } if strings.TrimSpace(device.MilestoneHardwareID) != "" || strings.TrimSpace(device.MilestoneRecordingServerID) != "" { writeError(w, http.StatusConflict, "Milestone-Geräte können nicht gelöscht werden") return } if _, err := tx.Exec(r.Context(), `DELETE FROM devices WHERE id = $1`, device.ID); err != nil { writeError(w, http.StatusInternalServerError, "Gerät konnte nicht gelöscht werden") return } if err := tx.Commit(r.Context()); err != nil { writeError(w, http.StatusInternalServerError, "Gerät konnte nicht gelöscht werden") return } if err := s.publishDeviceChangeEvent( r.Context(), user.ID, device.ID, "device.deleted", "Gerät gelöscht", formatText( "Gerät %s wurde gelöscht.", 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.deleted", }) } writeJSON(w, http.StatusOK, map[string]any{ "deletedDeviceId": device.ID, }) } func ensureDeviceExists(ctx context.Context, tx pgx.Tx, deviceID string) error { var exists bool err := tx.QueryRow( ctx, ` SELECT EXISTS( SELECT 1 FROM devices WHERE id = $1 ) `, deviceID, ).Scan(&exists) if err != nil { return err } if !exists { return errDeviceNotFound } return nil } func normalizeDeviceInput(input *CreateDeviceRequest) { input.InventoryNumber = strings.TrimSpace(input.InventoryNumber) input.Manufacturer = strings.TrimSpace(input.Manufacturer) 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.PhoneNumber = strings.TrimSpace(input.PhoneNumber) input.ComputerName = strings.TrimSpace(input.ComputerName) input.DeviceCategory = strings.TrimSpace(input.DeviceCategory) input.MilestonePort = strings.TrimSpace(input.MilestonePort) 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.LoanedToUserID = strings.TrimSpace(input.LoanedToUserID) input.LoanedUntil = strings.TrimSpace(input.LoanedUntil) input.Comment = strings.TrimSpace(input.Comment) if input.LoanStatus == "" { input.LoanStatus = "Verfügbar" } if !strings.EqualFold(input.LoanStatus, "Ausgeliehen") { input.LoanedToUserID = "" input.LoanedUntil = "" } } func syncDeviceRelations(ctx context.Context, tx pgx.Tx, deviceID string, relatedDeviceIDs []string) error { _, err := tx.Exec( ctx, ` DELETE FROM device_relations WHERE device_id = $1 OR related_device_id = $1 `, deviceID, ) if err != nil { return err } seen := map[string]bool{} for _, relatedDeviceID := range relatedDeviceIDs { relatedDeviceID = strings.TrimSpace(relatedDeviceID) if relatedDeviceID == "" || relatedDeviceID == deviceID { continue } if seen[relatedDeviceID] { continue } seen[relatedDeviceID] = true if err := ensureDeviceExists(ctx, tx, relatedDeviceID); err != nil { return err } if err := assignRelatedDevice(ctx, tx, deviceID, relatedDeviceID); err != nil { return err } if err := assignRelatedDevice(ctx, tx, relatedDeviceID, deviceID); err != nil { return err } } return nil } func assignRelatedDevice(ctx context.Context, tx pgx.Tx, deviceID string, relatedDeviceID string) error { _, err := tx.Exec( ctx, ` INSERT INTO device_relations ( device_id, related_device_id ) VALUES ($1, $2) ON CONFLICT (device_id, related_device_id) DO NOTHING `, deviceID, relatedDeviceID, ) return err } type deviceTransaction interface { QueryRow(ctx context.Context, sql string, args ...any) pgx.Row Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) }