478 lines
11 KiB
Go
478 lines
11 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"`
|
|
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"`
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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 (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
|
|
id,
|
|
inventory_number,
|
|
manufacturer,
|
|
model,
|
|
serial_number,
|
|
mac_address,
|
|
location,
|
|
loan_status,
|
|
is_bao_device,
|
|
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.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 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, "location", "Standort", "", input.Location)
|
|
changes = appendHistoryChange(changes, "loanStatus", "Verleih-Status", "", input.LoanStatus)
|
|
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, "location", "Standort", oldDevice.Location, input.Location)
|
|
changes = appendHistoryChange(changes, "loanStatus", "Verleih-Status", oldDevice.LoanStatus, input.LoanStatus)
|
|
changes = appendHistoryChange(changes, "isBaoDevice", "BAO-Gerät", formatHistoryBool(oldDevice.IsBaoDevice), formatHistoryBool(input.IsBaoDevice))
|
|
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
|
|
}
|