teg/backend/devices.go
2026-06-09 14:26:35 +02:00

838 lines
22 KiB
Go

// backend\devices.go
package main
import (
"context"
"errors"
"net/http"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
type Device struct {
ID string `json:"id"`
InventoryNumber string `json:"inventoryNumber"`
Manufacturer string `json:"manufacturer"`
Model string `json:"model"`
SerialNumber string `json:"serialNumber"`
MacAddress string `json:"macAddress"`
IPAddress string `json:"ipAddress"`
MilestonePort string `json:"milestonePort"`
Location string `json:"location"`
LoanStatus string `json:"loanStatus"`
IsBaoDevice bool `json:"isBaoDevice"`
Comment string `json:"comment"`
FirmwareVersion string `json:"firmwareVersion"`
FirmwareUpdate *DeviceFirmwareUpdate `json:"firmwareUpdate,omitempty"`
SupportBadges []DeviceSupportBadge `json:"supportBadges,omitempty"`
MilestoneRecordingServerID string `json:"milestoneRecordingServerId"`
MilestoneHardwareID string `json:"milestoneHardwareId"`
MilestoneDisplayName string `json:"milestoneDisplayName"`
MilestoneEnabled bool `json:"milestoneEnabled"`
MilestoneSyncedAt *time.Time `json:"milestoneSyncedAt,omitempty"`
RelatedDevices []DeviceShort `json:"relatedDevices"`
MilestoneChildDevices []MilestoneHardwareChildDevice `json:"milestoneChildDevices"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type DeviceFirmwareUpdate struct {
Available bool `json:"available"`
CurrentVersion string `json:"currentVersion"`
LatestVersion string `json:"latestVersion"`
SupportURL string `json:"supportUrl"`
Source string `json:"source"`
CheckedAt *time.Time `json:"checkedAt,omitempty"`
}
type DeviceSupportBadge struct {
Type string `json:"type"`
Label string `json:"label"`
Message string `json:"message"`
Severity string `json:"severity"`
SupportURL string `json:"supportUrl"`
}
type DeviceShort struct {
ID string `json:"id"`
InventoryNumber string `json:"inventoryNumber"`
Manufacturer string `json:"manufacturer"`
Model string `json:"model"`
}
type MilestoneHardwareChildDevice struct {
ID string `json:"id"`
MilestoneHardwareID string `json:"milestoneHardwareId"`
MilestoneDeviceID string `json:"milestoneDeviceId"`
DeviceType string `json:"deviceType"`
Name string `json:"name"`
DisplayName string `json:"displayName"`
ShortName string `json:"shortName"`
Enabled bool `json:"enabled"`
RecordingEnabled bool `json:"recordingEnabled"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type CreateDeviceRequest struct {
InventoryNumber string `json:"inventoryNumber"`
Manufacturer string `json:"manufacturer"`
Model string `json:"model"`
SerialNumber string `json:"serialNumber"`
MacAddress string `json:"macAddress"`
IPAddress string `json:"ipAddress"`
MilestonePort string `json:"milestonePort"`
FirmwareVersion string `json:"firmwareVersion"`
MilestoneDisplayName string `json:"milestoneDisplayName"`
MilestoneEnabled *bool `json:"milestoneEnabled,omitempty"`
Location string `json:"location"`
LoanStatus string `json:"loanStatus"`
IsBaoDevice bool `json:"isBaoDevice"`
Comment string `json:"comment"`
RelatedDeviceIDs []string `json:"relatedDeviceIds"`
}
type UpdateDeviceRequest = CreateDeviceRequest
type UpdateDeviceJournalEntryRequest struct {
Content string `json:"content"`
}
var errDeviceNotFound = errors.New("device does not exist")
func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
s.handleListDevices(w, r)
case http.MethodPost:
s.handleCreateDevice(w, r)
default:
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
}
}
func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
rows, err := s.db.Query(
r.Context(),
`
SELECT
id,
inventory_number,
manufacturer,
model,
serial_number,
mac_address,
COALESCE(ip_address, ''),
COALESCE(milestone_port, ''),
location,
loan_status,
is_bao_device,
comment,
COALESCE(firmware_version, ''),
COALESCE(milestone_recording_server_id, ''),
COALESCE(milestone_hardware_id, ''),
COALESCE(milestone_display_name, ''),
milestone_enabled,
milestone_synced_at,
created_at,
updated_at
FROM devices
ORDER BY inventory_number ASC
`,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Could not load devices")
return
}
defer rows.Close()
devices := make([]Device, 0)
deviceIndexMap := map[string]int{}
for rows.Next() {
var device Device
if err := rows.Scan(
&device.ID,
&device.InventoryNumber,
&device.Manufacturer,
&device.Model,
&device.SerialNumber,
&device.MacAddress,
&device.IPAddress,
&device.MilestonePort,
&device.Location,
&device.LoanStatus,
&device.IsBaoDevice,
&device.Comment,
&device.FirmwareVersion,
&device.MilestoneRecordingServerID,
&device.MilestoneHardwareID,
&device.MilestoneDisplayName,
&device.MilestoneEnabled,
&device.MilestoneSyncedAt,
&device.CreatedAt,
&device.UpdatedAt,
); err != nil {
writeError(w, http.StatusInternalServerError, "Could not read devices")
return
}
device.RelatedDevices = []DeviceShort{}
device.MilestoneChildDevices = []MilestoneHardwareChildDevice{}
devices = append(devices, device)
deviceIndexMap[device.ID] = len(devices) - 1
}
if err := rows.Err(); err != nil {
writeError(w, http.StatusInternalServerError, "Could not read devices")
return
}
relationRows, err := s.db.Query(
r.Context(),
`
SELECT
device_relations.device_id,
related_devices.id,
related_devices.inventory_number,
related_devices.manufacturer,
related_devices.model
FROM device_relations
INNER JOIN devices AS related_devices
ON related_devices.id = device_relations.related_device_id
ORDER BY related_devices.inventory_number ASC
`,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Could not load device relations")
return
}
defer relationRows.Close()
for relationRows.Next() {
var deviceID string
var relatedDevice DeviceShort
if err := relationRows.Scan(
&deviceID,
&relatedDevice.ID,
&relatedDevice.InventoryNumber,
&relatedDevice.Manufacturer,
&relatedDevice.Model,
); err != nil {
writeError(w, http.StatusInternalServerError, "Could not read device relations")
return
}
deviceIndex, exists := deviceIndexMap[deviceID]
if exists {
devices[deviceIndex].RelatedDevices = append(
devices[deviceIndex].RelatedDevices,
relatedDevice,
)
}
}
if err := relationRows.Err(); err != nil {
writeError(w, http.StatusInternalServerError, "Could not read device relations")
return
}
childRows, err := s.db.Query(
r.Context(),
`
SELECT
devices.id::TEXT,
milestone_hardware_child_devices.id::TEXT,
milestone_hardware_child_devices.milestone_hardware_id,
milestone_hardware_child_devices.milestone_device_id,
milestone_hardware_child_devices.device_type,
milestone_hardware_child_devices.name,
milestone_hardware_child_devices.display_name,
milestone_hardware_child_devices.short_name,
milestone_hardware_child_devices.enabled,
COALESCE(milestone_hardware_child_devices.recording_enabled, false),
milestone_hardware_child_devices.created_at,
milestone_hardware_child_devices.updated_at
FROM milestone_hardware_child_devices
INNER JOIN devices
ON devices.milestone_hardware_id = milestone_hardware_child_devices.milestone_hardware_id
WHERE COALESCE(devices.milestone_hardware_id, '') <> ''
ORDER BY
milestone_hardware_child_devices.device_type ASC,
milestone_hardware_child_devices.name ASC,
milestone_hardware_child_devices.display_name ASC
`,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Could not load Milestone child devices")
return
}
defer childRows.Close()
for childRows.Next() {
var deviceID string
var childDevice MilestoneHardwareChildDevice
if err := childRows.Scan(
&deviceID,
&childDevice.ID,
&childDevice.MilestoneHardwareID,
&childDevice.MilestoneDeviceID,
&childDevice.DeviceType,
&childDevice.Name,
&childDevice.DisplayName,
&childDevice.ShortName,
&childDevice.Enabled,
&childDevice.RecordingEnabled,
&childDevice.CreatedAt,
&childDevice.UpdatedAt,
); err != nil {
writeError(w, http.StatusInternalServerError, "Could not read Milestone child devices")
return
}
deviceIndex, exists := deviceIndexMap[deviceID]
if exists {
devices[deviceIndex].MilestoneChildDevices = append(
devices[deviceIndex].MilestoneChildDevices,
childDevice,
)
}
}
if err := childRows.Err(); err != nil {
writeError(w, http.StatusInternalServerError, "Could not read Milestone child devices")
return
}
s.enrichDevicesWithFirmwareUpdates(r.Context(), devices)
writeJSON(w, http.StatusOK, map[string]any{
"devices": devices,
})
}
func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
var input CreateDeviceRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON")
return
}
normalizeDeviceInput(&input)
if input.InventoryNumber == "" {
writeError(w, http.StatusBadRequest, "Inventur-Nr. ist erforderlich")
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, "Could not create device")
return
}
defer tx.Rollback(r.Context())
if err := ensureDeviceLookupValues(r.Context(), tx, input.Manufacturer, input.Location); err != nil {
writeError(w, http.StatusInternalServerError, "Hersteller oder Standort konnte nicht gespeichert werden")
return
}
var device Device
err = tx.QueryRow(
r.Context(),
`
INSERT INTO devices (
inventory_number,
manufacturer,
model,
serial_number,
mac_address,
ip_address,
milestone_port,
location,
loan_status,
is_bao_device,
comment,
firmware_version
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING
id,
inventory_number,
manufacturer,
model,
serial_number,
mac_address,
COALESCE(ip_address, ''),
COALESCE(milestone_port, ''),
location,
loan_status,
is_bao_device,
comment,
COALESCE(firmware_version, ''),
COALESCE(milestone_recording_server_id, ''),
COALESCE(milestone_hardware_id, ''),
COALESCE(milestone_display_name, ''),
COALESCE(milestone_enabled, true),
milestone_synced_at,
created_at,
updated_at
`,
input.InventoryNumber,
input.Manufacturer,
input.Model,
input.SerialNumber,
input.MacAddress,
input.IPAddress,
input.MilestonePort,
input.Location,
input.LoanStatus,
input.IsBaoDevice,
input.Comment,
input.FirmwareVersion,
).Scan(
&device.ID,
&device.InventoryNumber,
&device.Manufacturer,
&device.Model,
&device.SerialNumber,
&device.MacAddress,
&device.IPAddress,
&device.MilestonePort,
&device.Location,
&device.LoanStatus,
&device.IsBaoDevice,
&device.Comment,
&device.FirmwareVersion,
&device.MilestoneRecordingServerID,
&device.MilestoneHardwareID,
&device.MilestoneDisplayName,
&device.MilestoneEnabled,
&device.MilestoneSyncedAt,
&device.CreatedAt,
&device.UpdatedAt,
)
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
writeError(w, http.StatusConflict, "Gerät mit dieser Inventur-Nr., Seriennummer oder MAC-Adresse existiert bereits")
return
}
writeError(w, http.StatusInternalServerError, "Could not create device")
return
}
if err := syncDeviceRelations(r.Context(), tx, device.ID, input.RelatedDeviceIDs); err != nil {
if errors.Is(err, errDeviceNotFound) {
writeError(w, http.StatusBadRequest, "Zugeordnetes Gerät wurde nicht gefunden")
return
}
writeError(w, http.StatusInternalServerError, "Could not assign related device")
return
}
changes, err := buildDeviceCreateHistoryChanges(r.Context(), tx, input)
if err != nil {
writeError(w, http.StatusInternalServerError, "Could not create device history")
return
}
if err := recordDeviceHistory(r.Context(), tx, device.ID, user, "created", changes); err != nil {
writeError(w, http.StatusInternalServerError, "Could not create device history")
return
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "Could not create device")
return
}
device.RelatedDevices = []DeviceShort{}
if err := s.publishDeviceChangeEvent(
r.Context(),
user.ID,
device.ID,
"device.created",
"Gerät erstellt",
formatText(
"Gerät %s wurde erstellt.",
device.InventoryNumber,
),
LogFields{
"inventoryNumber": device.InventoryNumber,
"manufacturer": device.Manufacturer,
"model": device.Model,
},
); err != nil {
logError("Device-SSE-Event konnte nicht gesendet werden", err, LogFields{
"deviceId": device.ID,
"type": "device.created",
})
}
writeJSON(w, http.StatusCreated, map[string]any{
"device": device,
})
}
func (s *Server) handleUpdateDevice(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 UpdateDeviceRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON")
return
}
normalizeDeviceInput(&input)
if input.InventoryNumber == "" {
writeError(w, http.StatusBadRequest, "Inventur-Nr. ist erforderlich")
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, "Could not update device")
return
}
defer tx.Rollback(r.Context())
if err := ensureDeviceLookupValues(r.Context(), tx, input.Manufacturer, input.Location); err != nil {
writeError(w, http.StatusInternalServerError, "Hersteller oder Standort konnte nicht gespeichert werden")
return
}
oldDevice, oldRelatedDeviceIDs, 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, "Could not load device history state")
return
}
nextMilestoneEnabled := oldDevice.MilestoneEnabled
if input.MilestoneEnabled != nil {
nextMilestoneEnabled = *input.MilestoneEnabled
}
var device Device
err = tx.QueryRow(
r.Context(),
`
UPDATE devices
SET
inventory_number = $2,
manufacturer = $3,
model = $4,
serial_number = $5,
mac_address = $6,
ip_address = $7,
milestone_port = $8,
location = $9,
loan_status = $10,
is_bao_device = $11,
comment = $12,
firmware_version = $13,
milestone_display_name = $14,
milestone_enabled = $15,
milestone_synced_at = CASE
WHEN COALESCE(milestone_hardware_id, '') <> ''
AND (
COALESCE(milestone_display_name, '') <> $14
OR COALESCE(ip_address, '') <> $7
OR COALESCE(milestone_port, '') <> $8
OR milestone_enabled <> $15
)
THEN now()
ELSE milestone_synced_at
END,
updated_at = now()
WHERE id = $1
RETURNING
id,
inventory_number,
manufacturer,
model,
serial_number,
mac_address,
COALESCE(ip_address, ''),
COALESCE(milestone_port, ''),
location,
loan_status,
is_bao_device,
comment,
COALESCE(firmware_version, ''),
COALESCE(milestone_recording_server_id, ''),
COALESCE(milestone_hardware_id, ''),
COALESCE(milestone_display_name, ''),
COALESCE(milestone_enabled, true),
milestone_synced_at,
created_at,
updated_at
`,
deviceID,
input.InventoryNumber,
input.Manufacturer,
input.Model,
input.SerialNumber,
input.MacAddress,
input.IPAddress,
input.MilestonePort,
input.Location,
input.LoanStatus,
input.IsBaoDevice,
input.Comment,
input.FirmwareVersion,
input.MilestoneDisplayName,
nextMilestoneEnabled,
).Scan(
&device.ID,
&device.InventoryNumber,
&device.Manufacturer,
&device.Model,
&device.SerialNumber,
&device.MacAddress,
&device.IPAddress,
&device.MilestonePort,
&device.Location,
&device.LoanStatus,
&device.IsBaoDevice,
&device.Comment,
&device.FirmwareVersion,
&device.MilestoneRecordingServerID,
&device.MilestoneHardwareID,
&device.MilestoneDisplayName,
&device.MilestoneEnabled,
&device.MilestoneSyncedAt,
&device.CreatedAt,
&device.UpdatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "Gerät wurde nicht gefunden")
return
}
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
writeError(w, http.StatusConflict, "Gerät mit dieser Inventur-Nr., Seriennummer oder MAC-Adresse existiert bereits")
return
}
writeError(w, http.StatusInternalServerError, "Could not update device")
return
}
if err := syncDeviceRelations(r.Context(), tx, device.ID, input.RelatedDeviceIDs); err != nil {
if errors.Is(err, errDeviceNotFound) {
writeError(w, http.StatusBadRequest, "Zugeordnetes Gerät wurde nicht gefunden")
return
}
writeError(w, http.StatusInternalServerError, "Could not update device relations")
return
}
changes, err := buildDeviceUpdateHistoryChanges(r.Context(), tx, oldDevice, oldRelatedDeviceIDs, input)
if err != nil {
writeError(w, http.StatusInternalServerError, "Could not create device history")
return
}
if len(changes) > 0 {
if err := recordDeviceHistory(r.Context(), tx, device.ID, user, "updated", changes); err != nil {
writeError(w, http.StatusInternalServerError, "Could not create device history")
return
}
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "Could not update device")
return
}
device.RelatedDevices = []DeviceShort{}
if err := s.publishDeviceChangeEvent(
r.Context(),
user.ID,
device.ID,
"device.updated",
"Gerät geändert",
formatText(
"Gerät %s wurde geändert.",
device.InventoryNumber,
),
LogFields{
"inventoryNumber": device.InventoryNumber,
"manufacturer": device.Manufacturer,
"model": device.Model,
"changes": changes,
},
); err != nil {
logError("Device-SSE-Event konnte nicht gesendet werden", err, LogFields{
"deviceId": device.ID,
"type": "device.updated",
})
}
writeJSON(w, http.StatusOK, map[string]any{
"device": device,
})
}
func ensureDeviceExists(ctx context.Context, tx pgx.Tx, deviceID string) error {
var exists bool
err := tx.QueryRow(
ctx,
`
SELECT EXISTS(
SELECT 1
FROM devices
WHERE id = $1
)
`,
deviceID,
).Scan(&exists)
if err != nil {
return err
}
if !exists {
return errDeviceNotFound
}
return nil
}
func normalizeDeviceInput(input *CreateDeviceRequest) {
input.InventoryNumber = strings.TrimSpace(input.InventoryNumber)
input.Manufacturer = strings.TrimSpace(input.Manufacturer)
input.Model = strings.TrimSpace(input.Model)
input.SerialNumber = strings.TrimSpace(input.SerialNumber)
input.MacAddress = strings.TrimSpace(input.MacAddress)
input.IPAddress = strings.TrimSpace(input.IPAddress)
input.MilestonePort = strings.TrimSpace(input.MilestonePort)
input.FirmwareVersion = strings.TrimSpace(input.FirmwareVersion)
input.MilestoneDisplayName = strings.TrimSpace(input.MilestoneDisplayName)
input.Location = strings.TrimSpace(input.Location)
input.LoanStatus = strings.TrimSpace(input.LoanStatus)
input.Comment = strings.TrimSpace(input.Comment)
if input.LoanStatus == "" {
input.LoanStatus = "Verfügbar"
}
}
func syncDeviceRelations(ctx context.Context, tx pgx.Tx, deviceID string, relatedDeviceIDs []string) error {
_, err := tx.Exec(
ctx,
`
DELETE FROM device_relations
WHERE device_id = $1 OR related_device_id = $1
`,
deviceID,
)
if err != nil {
return err
}
seen := map[string]bool{}
for _, relatedDeviceID := range relatedDeviceIDs {
relatedDeviceID = strings.TrimSpace(relatedDeviceID)
if relatedDeviceID == "" || relatedDeviceID == deviceID {
continue
}
if seen[relatedDeviceID] {
continue
}
seen[relatedDeviceID] = true
if err := ensureDeviceExists(ctx, tx, relatedDeviceID); err != nil {
return err
}
if err := assignRelatedDevice(ctx, tx, deviceID, relatedDeviceID); err != nil {
return err
}
if err := assignRelatedDevice(ctx, tx, relatedDeviceID, deviceID); err != nil {
return err
}
}
return nil
}
func assignRelatedDevice(ctx context.Context, tx pgx.Tx, deviceID string, relatedDeviceID string) error {
_, err := tx.Exec(
ctx,
`
INSERT INTO device_relations (
device_id,
related_device_id
)
VALUES ($1, $2)
ON CONFLICT (device_id, related_device_id) DO NOTHING
`,
deviceID,
relatedDeviceID,
)
return err
}
type deviceTransaction interface {
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
}