// backend\history.go 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"` UserAvatar string `json:"userAvatar"` Action string `json:"action"` Changes []DeviceHistoryChange `json:"changes"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` CanEdit bool `json:"canEdit"` } type DeviceHistoryChange struct { Field string `json:"field"` Label string `json:"label"` OldValue string `json:"oldValue"` NewValue string `json:"newValue"` } type CreateDeviceJournalEntryRequest struct { Content string `json:"content"` } 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 } user, ok := userFromContext(r.Context()) if !ok { writeError(w, http.StatusUnauthorized, "Unauthorized") 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'), COALESCE(u.avatar, ''), h.action, h.changes, h.created_at, COALESCE(h.updated_at, 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.UserAvatar, &entry.Action, &changesRaw, &entry.CreatedAt, &entry.UpdatedAt, ); 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 } } entry.CanEdit = entry.Action == "journal" && entry.UserID == user.ID && time.Since(entry.CreatedAt) <= 10*time.Minute 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 (s *Server) handleCreateDeviceJournalEntry(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 CreateDeviceJournalEntryRequest if err := readJSON(r, &input); err != nil { writeError(w, http.StatusBadRequest, "Invalid JSON") return } content := strings.TrimSpace(input.Content) if content == "" { writeError(w, http.StatusBadRequest, "Journal-Eintrag darf nicht leer sein") 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, "Journal-Eintrag konnte nicht erstellt werden") return } defer tx.Rollback(r.Context()) device, _, 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, "Gerät konnte nicht geladen werden") return } changes := []DeviceHistoryChange{ { Field: "journal", Label: "Journal", OldValue: "", NewValue: content, }, } if err := recordDeviceHistory(r.Context(), tx, device.ID, user, "journal", changes); err != nil { writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht gespeichert werden") return } if err := tx.Commit(r.Context()); err != nil { writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht gespeichert werden") return } _ = s.createDeviceJournalEvent( r.Context(), user, device, map[string]any{ "inventoryNumber": device.InventoryNumber, "manufacturer": device.Manufacturer, "model": device.Model, "action": "created", }, ) writeJSON(w, http.StatusCreated, map[string]any{ "ok": true, }) } 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 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.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, 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, ''), devices.created_at, devices.updated_at FROM devices LEFT JOIN users AS loaned_user ON loaned_user.id = devices.loaned_to_user_id WHERE devices.id = $1 LIMIT 1 `, deviceID, ).Scan( &device.ID, &device.InventoryNumber, &device.Manufacturer, &device.Model, &device.SerialNumber, &device.MacAddress, &device.IPAddress, &device.PhoneNumber, &device.ComputerName, &device.DeviceCategory, &device.FirmwareVersion, &device.MilestoneRecordingServerID, &device.MilestoneHardwareID, &device.MilestoneDisplayName, &device.MilestoneEnabled, &device.MilestoneSyncedAt, &device.Location, &device.LoanStatus, &device.LoanedToUserID, &device.LoanedToDisplayName, &device.LoanedUntil, &device.IsBaoDevice, &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 (s *Server) handleUpdateDeviceJournalEntry(w http.ResponseWriter, r *http.Request) { deviceID := strings.TrimSpace(r.PathValue("id")) historyID := strings.TrimSpace(r.PathValue("historyId")) if deviceID == "" { writeError(w, http.StatusBadRequest, "Geräte-ID fehlt") return } if historyID == "" { writeError(w, http.StatusBadRequest, "Journal-ID fehlt") return } var input UpdateDeviceJournalEntryRequest if err := readJSON(r, &input); err != nil { writeError(w, http.StatusBadRequest, "Invalid JSON") return } content := strings.TrimSpace(input.Content) if content == "" { writeError(w, http.StatusBadRequest, "Journal-Eintrag darf nicht leer sein") 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, "Journal-Eintrag konnte nicht gespeichert werden") return } defer tx.Rollback(r.Context()) if err := ensureDeviceExists(r.Context(), tx, deviceID); err != nil { if errors.Is(err, errDeviceNotFound) { writeError(w, http.StatusNotFound, "Gerät wurde nicht gefunden") return } writeError(w, http.StatusInternalServerError, "Gerät konnte nicht geladen werden") return } var changesRaw []byte var createdAt time.Time err = tx.QueryRow( r.Context(), ` SELECT changes, created_at FROM device_history WHERE id = $1 AND device_id = $2 AND user_id = $3 AND action = 'journal' LIMIT 1 `, historyID, deviceID, user.ID, ).Scan(&changesRaw, &createdAt) if errors.Is(err, pgx.ErrNoRows) { writeError(w, http.StatusNotFound, "Journal-Eintrag wurde nicht gefunden oder darf nicht bearbeitet werden") return } if err != nil { writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht geladen werden") return } if time.Since(createdAt) > 10*time.Minute { writeError(w, http.StatusForbidden, "Journal-Eintrag kann nur innerhalb von 10 Minuten bearbeitet werden") return } var oldChanges []DeviceHistoryChange if len(changesRaw) > 0 { if err := json.Unmarshal(changesRaw, &oldChanges); err != nil { writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht gelesen werden") return } } oldContent := "" for _, change := range oldChanges { if change.Field == "journal" { oldContent = change.NewValue break } } if strings.TrimSpace(oldContent) == content { writeJSON(w, http.StatusOK, map[string]any{ "ok": true, }) return } changes := []DeviceHistoryChange{ { Field: "journal", Label: "Journal", OldValue: oldContent, NewValue: content, }, } changesJSON, err := json.Marshal(changes) if err != nil { writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht vorbereitet werden") return } commandTag, err := tx.Exec( r.Context(), ` UPDATE device_history SET changes = $4::JSONB, updated_at = now() WHERE id = $1 AND device_id = $2 AND user_id = $3 AND action = 'journal' AND created_at >= now() - interval '10 minutes' `, historyID, deviceID, user.ID, string(changesJSON), ) if err != nil { writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht gespeichert werden") return } if commandTag.RowsAffected() == 0 { writeError(w, http.StatusForbidden, "Journal-Eintrag kann nicht mehr bearbeitet werden") return } if err := tx.Commit(r.Context()); err != nil { writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht gespeichert werden") return } if err := s.publishDeviceChangeEvent( r.Context(), user.ID, deviceID, "device.journal", "Geräte-Journal aktualisiert", "Ein Geräte-Journal-Eintrag wurde aktualisiert.", LogFields{ "deviceId": deviceID, "historyId": historyID, "action": "updated", }, ); err != nil { logError("Device-Journal-SSE-Event konnte nicht gesendet werden", err, LogFields{ "deviceId": deviceID, "historyId": historyID, "type": "device.journal", }) } writeJSON(w, http.StatusOK, map[string]any{ "ok": true, }) } func formatHistoryBool(value bool) string { if value { return "Ja" } return "Nein" } 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, "phoneNumber", "Rufnummer", "", input.PhoneNumber) changes = appendHistoryChange(changes, "computerName", "Computername", "", input.ComputerName) changes = appendHistoryChange(changes, "deviceCategory", "Kategorie", "", input.DeviceCategory) changes = appendHistoryChange(changes, "location", "Standort", "", input.Location) changes = appendHistoryChange(changes, "loanStatus", "Verleih-Status", "", input.LoanStatus) loanedToUser, err := formatLoanedUserLabel(ctx, tx, input.LoanedToUserID) if err != nil { return nil, err } changes = appendHistoryChange(changes, "loanedToUserId", "Verliehen an", "", loanedToUser) changes = appendHistoryChange(changes, "loanedUntil", "Verliehen bis", "", input.LoanedUntil) changes = appendHistoryChange(changes, "isBaoDevice", "BAO-Gerät", "Nein", formatHistoryBool(input.IsBaoDevice)) 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, "phoneNumber", "Rufnummer", oldDevice.PhoneNumber, input.PhoneNumber) changes = appendHistoryChange(changes, "computerName", "Computername", oldDevice.ComputerName, input.ComputerName) changes = appendHistoryChange(changes, "deviceCategory", "Kategorie", oldDevice.DeviceCategory, input.DeviceCategory) changes = appendHistoryChange(changes, "location", "Standort", oldDevice.Location, input.Location) changes = appendHistoryChange(changes, "loanStatus", "Verleih-Status", oldDevice.LoanStatus, input.LoanStatus) loanedToUser, err := formatLoanedUserLabel(ctx, tx, input.LoanedToUserID) if err != nil { return nil, err } changes = appendHistoryChange(changes, "loanedToUserId", "Verliehen an", oldDevice.LoanedToDisplayName, loanedToUser) changes = appendHistoryChange(changes, "loanedUntil", "Verliehen bis", oldDevice.LoanedUntil, input.LoanedUntil) changes = appendHistoryChange(changes, "isBaoDevice", "BAO-Gerät", formatHistoryBool(oldDevice.IsBaoDevice), formatHistoryBool(input.IsBaoDevice)) changes = appendHistoryChange(changes, "comment", "Kommentar", oldDevice.Comment, input.Comment) changes = appendHistoryChange(changes, "milestoneDisplayName", "Milestone-Anzeigename", oldDevice.MilestoneDisplayName, input.MilestoneDisplayName) changes = appendHistoryChange(changes, "ipAddress", "IP-Adresse", oldDevice.IPAddress, input.IPAddress) if input.MilestoneEnabled != nil { changes = appendHistoryChange( changes, "milestoneEnabled", "Milestone-Status", formatHistoryBool(oldDevice.MilestoneEnabled), formatHistoryBool(*input.MilestoneEnabled), ) } 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 formatLoanedUserLabel(ctx context.Context, tx pgx.Tx, userID string) (string, error) { userID = strings.TrimSpace(userID) if userID == "" { return "", nil } var label string err := tx.QueryRow( ctx, ` SELECT COALESCE(NULLIF(display_name, ''), NULLIF(username, ''), email, '') FROM users WHERE id = $1 LIMIT 1 `, userID, ).Scan(&label) if errors.Is(err, pgx.ErrNoRows) { return userID, nil } if err != nil { return "", err } return label, 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 }