teg/backend/history.go
2026-05-12 15:29:56 +02:00

378 lines
8.3 KiB
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"`
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
}