// 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"` Location string `json:"location"` LoanStatus string `json:"loanStatus"` Comment string `json:"comment"` RelatedDevices []DeviceShort `json:"relatedDevices"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } type DeviceShort struct { ID string `json:"id"` InventoryNumber string `json:"inventoryNumber"` Manufacturer string `json:"manufacturer"` Model string `json:"model"` } 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"` Comment string `json:"comment"` RelatedDeviceIDs []string `json:"relatedDeviceIds"` } type UpdateDeviceRequest = CreateDeviceRequest 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) handleListDevices(w http.ResponseWriter, r *http.Request) { rows, err := s.db.Query( r.Context(), ` SELECT id, inventory_number, manufacturer, model, serial_number, mac_address, location, loan_status, comment, created_at, updated_at FROM devices ORDER BY inventory_number ASC `, ) if err != nil { writeError(w, http.StatusInternalServerError, "Could not load devices") return } defer rows.Close() devices := make([]Device, 0) deviceMap := map[string]*Device{} for rows.Next() { var device Device if err := rows.Scan( &device.ID, &device.InventoryNumber, &device.Manufacturer, &device.Model, &device.SerialNumber, &device.MacAddress, &device.Location, &device.LoanStatus, &device.Comment, &device.CreatedAt, &device.UpdatedAt, ); err != nil { writeError(w, http.StatusInternalServerError, "Could not read devices") return } device.RelatedDevices = []DeviceShort{} devices = append(devices, device) deviceMap[device.ID] = &devices[len(devices)-1] } if err := rows.Err(); 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, related_devices.inventory_number, related_devices.manufacturer, 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 { 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 { writeError(w, http.StatusInternalServerError, "Could not read device relations") return } device, exists := deviceMap[deviceID] if exists { device.RelatedDevices = append(device.RelatedDevices, relatedDevice) } } if err := relationRows.Err(); err != nil { writeError(w, http.StatusInternalServerError, "Could not read device relations") return } 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()) var device Device err = tx.QueryRow( r.Context(), ` INSERT INTO devices ( inventory_number, manufacturer, model, serial_number, mac_address, location, loan_status, comment ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, inventory_number, manufacturer, model, serial_number, mac_address, location, loan_status, comment, created_at, updated_at `, input.InventoryNumber, input.Manufacturer, input.Model, input.SerialNumber, input.MacAddress, input.Location, input.LoanStatus, input.Comment, ).Scan( &device.ID, &device.InventoryNumber, &device.Manufacturer, &device.Model, &device.SerialNumber, &device.MacAddress, &device.Location, &device.LoanStatus, &device.Comment, &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{} 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()) 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 { writeError(w, http.StatusInternalServerError, "Could not load device history state") return } var device Device err = tx.QueryRow( r.Context(), ` UPDATE devices SET inventory_number = $2, manufacturer = $3, model = $4, serial_number = $5, mac_address = $6, location = $7, loan_status = $8, comment = $9, updated_at = now() WHERE id = $1 RETURNING id, inventory_number, manufacturer, model, serial_number, mac_address, location, loan_status, comment, created_at, updated_at `, deviceID, input.InventoryNumber, input.Manufacturer, input.Model, input.SerialNumber, input.MacAddress, input.Location, input.LoanStatus, input.Comment, ).Scan( &device.ID, &device.InventoryNumber, &device.Manufacturer, &device.Model, &device.SerialNumber, &device.MacAddress, &device.Location, &device.LoanStatus, &device.Comment, &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{} writeJSON(w, http.StatusOK, map[string]any{ "device": device, }) } 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.Location = strings.TrimSpace(input.Location) input.LoanStatus = strings.TrimSpace(input.LoanStatus) input.Comment = strings.TrimSpace(input.Comment) if input.LoanStatus == "" { input.LoanStatus = "Verfügbar" } } 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) }