teg/backend/history.go
2026-06-22 13:57:42 +02:00

766 lines
19 KiB
Go

// 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
}