package main import ( "context" "encoding/json" "errors" "net/http" "strings" "time" "github.com/jackc/pgx/v5" ) type DeviceHistoryEntry struct { ID string `json:"id"` DeviceID string `json:"deviceId"` UserID string `json:"userId"` UserDisplayName string `json:"userDisplayName"` Action string `json:"action"` Changes []DeviceHistoryChange `json:"changes"` CreatedAt time.Time `json:"createdAt"` } type DeviceHistoryChange struct { Field string `json:"field"` Label string `json:"label"` OldValue string `json:"oldValue"` NewValue string `json:"newValue"` } func (s *Server) handleListDeviceHistory(w http.ResponseWriter, r *http.Request) { deviceID := strings.TrimSpace(r.PathValue("id")) if deviceID == "" { writeError(w, http.StatusBadRequest, "Geräte-ID fehlt") return } rows, err := s.db.Query( r.Context(), ` SELECT h.id, h.device_id, COALESCE(h.user_id::TEXT, ''), COALESCE(NULLIF(u.display_name, ''), NULLIF(u.username, ''), u.email, 'Unbekannt'), h.action, h.changes, h.created_at FROM device_history h LEFT JOIN users u ON u.id = h.user_id WHERE h.device_id = $1 ORDER BY h.created_at DESC `, deviceID, ) if err != nil { writeError(w, http.StatusInternalServerError, "Could not load device history") return } defer rows.Close() history := []DeviceHistoryEntry{} for rows.Next() { var entry DeviceHistoryEntry var changesRaw []byte if err := rows.Scan( &entry.ID, &entry.DeviceID, &entry.UserID, &entry.UserDisplayName, &entry.Action, &changesRaw, &entry.CreatedAt, ); err != nil { writeError(w, http.StatusInternalServerError, "Could not read device history") return } if len(changesRaw) > 0 { if err := json.Unmarshal(changesRaw, &entry.Changes); err != nil { writeError(w, http.StatusInternalServerError, "Could not parse device history") return } } history = append(history, entry) } if err := rows.Err(); err != nil { writeError(w, http.StatusInternalServerError, "Could not read device history") return } writeJSON(w, http.StatusOK, map[string]any{ "history": history, }) } func userFromContext(ctx context.Context) (User, bool) { user, ok := ctx.Value(userContextKey).(User) return user, ok } func recordDeviceHistory( ctx context.Context, tx pgx.Tx, deviceID string, user User, action string, changes []DeviceHistoryChange, ) error { changesJSON, err := json.Marshal(changes) if err != nil { return err } _, err = tx.Exec( ctx, ` INSERT INTO device_history ( device_id, user_id, action, changes ) VALUES ($1, $2, $3, $4::JSONB) `, deviceID, user.ID, action, string(changesJSON), ) return err } func getDeviceForHistory(ctx context.Context, tx pgx.Tx, deviceID string) (Device, []string, error) { var device Device err := tx.QueryRow( ctx, ` SELECT id, inventory_number, manufacturer, model, serial_number, mac_address, location, loan_status, comment, created_at, updated_at FROM devices WHERE id = $1 LIMIT 1 `, deviceID, ).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 { return device, nil, err } rows, err := tx.Query( ctx, ` SELECT related_device_id FROM device_relations WHERE device_id = $1 ORDER BY related_device_id ASC `, deviceID, ) if err != nil { return device, nil, err } defer rows.Close() relatedDeviceIDs := []string{} for rows.Next() { var relatedDeviceID string if err := rows.Scan(&relatedDeviceID); err != nil { return device, nil, err } relatedDeviceIDs = append(relatedDeviceIDs, relatedDeviceID) } if err := rows.Err(); err != nil { return device, nil, err } return device, relatedDeviceIDs, nil } func buildDeviceCreateHistoryChanges( ctx context.Context, tx pgx.Tx, input CreateDeviceRequest, ) ([]DeviceHistoryChange, error) { changes := []DeviceHistoryChange{} changes = appendHistoryChange(changes, "inventoryNumber", "Inventur-Nr.", "", input.InventoryNumber) changes = appendHistoryChange(changes, "manufacturer", "Hersteller", "", input.Manufacturer) changes = appendHistoryChange(changes, "model", "Modell", "", input.Model) changes = appendHistoryChange(changes, "serialNumber", "Seriennummer", "", input.SerialNumber) changes = appendHistoryChange(changes, "macAddress", "MAC-Adresse", "", input.MacAddress) changes = appendHistoryChange(changes, "location", "Standort", "", input.Location) changes = appendHistoryChange(changes, "loanStatus", "Verleih-Status", "", input.LoanStatus) changes = appendHistoryChange(changes, "comment", "Kommentar", "", input.Comment) relatedDevices, err := formatRelatedDeviceLabels(ctx, tx, input.RelatedDeviceIDs) if err != nil { return nil, err } changes = appendHistoryChange(changes, "relatedDeviceIds", "Zugeordnete Geräte / Zubehör", "", relatedDevices) return changes, nil } func buildDeviceUpdateHistoryChanges( ctx context.Context, tx pgx.Tx, oldDevice Device, oldRelatedDeviceIDs []string, input UpdateDeviceRequest, ) ([]DeviceHistoryChange, error) { changes := []DeviceHistoryChange{} changes = appendHistoryChange(changes, "inventoryNumber", "Inventur-Nr.", oldDevice.InventoryNumber, input.InventoryNumber) changes = appendHistoryChange(changes, "manufacturer", "Hersteller", oldDevice.Manufacturer, input.Manufacturer) changes = appendHistoryChange(changes, "model", "Modell", oldDevice.Model, input.Model) changes = appendHistoryChange(changes, "serialNumber", "Seriennummer", oldDevice.SerialNumber, input.SerialNumber) changes = appendHistoryChange(changes, "macAddress", "MAC-Adresse", oldDevice.MacAddress, input.MacAddress) changes = appendHistoryChange(changes, "location", "Standort", oldDevice.Location, input.Location) changes = appendHistoryChange(changes, "loanStatus", "Verleih-Status", oldDevice.LoanStatus, input.LoanStatus) changes = appendHistoryChange(changes, "comment", "Kommentar", oldDevice.Comment, input.Comment) oldRelatedDevices, err := formatRelatedDeviceLabels(ctx, tx, oldRelatedDeviceIDs) if err != nil { return nil, err } newRelatedDevices, err := formatRelatedDeviceLabels(ctx, tx, input.RelatedDeviceIDs) if err != nil { return nil, err } changes = appendHistoryChange( changes, "relatedDeviceIds", "Zugeordnete Geräte / Zubehör", oldRelatedDevices, newRelatedDevices, ) return changes, nil } func appendHistoryChange( changes []DeviceHistoryChange, field string, label string, oldValue string, newValue string, ) []DeviceHistoryChange { oldValue = strings.TrimSpace(oldValue) newValue = strings.TrimSpace(newValue) if oldValue == newValue { return changes } return append(changes, DeviceHistoryChange{ Field: field, Label: label, OldValue: displayHistoryValue(oldValue), NewValue: displayHistoryValue(newValue), }) } func displayHistoryValue(value string) string { value = strings.TrimSpace(value) if value == "" { return "-" } return value } func formatRelatedDeviceLabels(ctx context.Context, tx pgx.Tx, deviceIDs []string) (string, error) { normalizedDeviceIDs := normalizeDeviceIDs(deviceIDs) if len(normalizedDeviceIDs) == 0 { return "-", nil } labels := []string{} for _, deviceID := range normalizedDeviceIDs { var label string err := tx.QueryRow( ctx, ` SELECT TRIM(CONCAT_WS(' ', inventory_number, NULLIF(manufacturer, ''), NULLIF(model, ''))) FROM devices WHERE id = $1 LIMIT 1 `, deviceID, ).Scan(&label) if errors.Is(err, pgx.ErrNoRows) { return "", errDeviceNotFound } if err != nil { return "", err } label = strings.TrimSpace(label) if label == "" { label = deviceID } labels = append(labels, label) } return strings.Join(labels, ", "), nil } func normalizeDeviceIDs(deviceIDs []string) []string { seen := map[string]bool{} result := []string{} for _, deviceID := range deviceIDs { deviceID = strings.TrimSpace(deviceID) if deviceID == "" { continue } if seen[deviceID] { continue } seen[deviceID] = true result = append(result, deviceID) } return result }