1088 lines
30 KiB
Go
1088 lines
30 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"`
|
|
PhoneNumber string `json:"phoneNumber"`
|
|
ComputerName string `json:"computerName"`
|
|
DeviceCategory string `json:"deviceCategory"`
|
|
MilestonePort string `json:"milestonePort"`
|
|
Location string `json:"location"`
|
|
LoanStatus string `json:"loanStatus"`
|
|
LoanedToUserID string `json:"loanedToUserId"`
|
|
LoanedToDisplayName string `json:"loanedToDisplayName"`
|
|
LoanedUntil string `json:"loanedUntil"`
|
|
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 DeviceLoanUser struct {
|
|
ID string `json:"id"`
|
|
Username string `json:"username"`
|
|
DisplayName string `json:"displayName"`
|
|
Email string `json:"email"`
|
|
Avatar string `json:"avatar"`
|
|
Unit string `json:"unit"`
|
|
}
|
|
|
|
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"`
|
|
PhoneNumber string `json:"phoneNumber"`
|
|
ComputerName string `json:"computerName"`
|
|
DeviceCategory string `json:"deviceCategory"`
|
|
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"`
|
|
LoanedToUserID string `json:"loanedToUserId"`
|
|
LoanedUntil string `json:"loanedUntil"`
|
|
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) handleListDeviceLoanUsers(w http.ResponseWriter, r *http.Request) {
|
|
rows, err := s.db.Query(
|
|
r.Context(),
|
|
`
|
|
SELECT
|
|
id::TEXT,
|
|
COALESCE(username, ''),
|
|
COALESCE(display_name, ''),
|
|
email,
|
|
COALESCE(avatar, ''),
|
|
COALESCE(unit, '')
|
|
FROM users
|
|
ORDER BY lower(COALESCE(NULLIF(display_name, ''), username, email)), lower(email)
|
|
`,
|
|
)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Benutzer konnten nicht geladen werden")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
users := []DeviceLoanUser{}
|
|
|
|
for rows.Next() {
|
|
var user DeviceLoanUser
|
|
if err := rows.Scan(
|
|
&user.ID,
|
|
&user.Username,
|
|
&user.DisplayName,
|
|
&user.Email,
|
|
&user.Avatar,
|
|
&user.Unit,
|
|
); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Benutzer konnten nicht gelesen werden")
|
|
return
|
|
}
|
|
|
|
users = append(users, user)
|
|
}
|
|
|
|
if err := rows.Err(); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Benutzer konnten nicht vollständig geladen werden")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"users": users})
|
|
}
|
|
|
|
func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
|
|
rows, err := s.db.Query(
|
|
r.Context(),
|
|
`
|
|
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.milestone_port, ''),
|
|
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, ''),
|
|
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,
|
|
devices.created_at,
|
|
devices.updated_at
|
|
FROM devices
|
|
LEFT JOIN users AS loaned_user
|
|
ON loaned_user.id = devices.loaned_to_user_id
|
|
ORDER BY devices.inventory_number ASC
|
|
`,
|
|
)
|
|
|
|
if err != nil {
|
|
logError("Geräte konnten nicht geladen werden", 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.PhoneNumber,
|
|
&device.ComputerName,
|
|
&device.DeviceCategory,
|
|
&device.MilestonePort,
|
|
&device.Location,
|
|
&device.LoanStatus,
|
|
&device.LoanedToUserID,
|
|
&device.LoanedToDisplayName,
|
|
&device.LoanedUntil,
|
|
&device.IsBaoDevice,
|
|
&device.Comment,
|
|
&device.FirmwareVersion,
|
|
&device.MilestoneRecordingServerID,
|
|
&device.MilestoneHardwareID,
|
|
&device.MilestoneDisplayName,
|
|
&device.MilestoneEnabled,
|
|
&device.MilestoneSyncedAt,
|
|
&device.CreatedAt,
|
|
&device.UpdatedAt,
|
|
); err != nil {
|
|
logError("Geräte konnten nicht gelesen werden", 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 {
|
|
logError("Geräte konnten nicht vollständig gelesen werden", 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,
|
|
COALESCE(related_devices.inventory_number, ''),
|
|
COALESCE(related_devices.manufacturer, ''),
|
|
COALESCE(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 {
|
|
logError("Geräte-Zuordnungen konnten nicht geladen werden", 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 {
|
|
logError("Geräte-Zuordnungen konnten nicht gelesen werden", 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 {
|
|
logError("Geräte-Zuordnungen konnten nicht vollständig gelesen werden", 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,
|
|
COALESCE(milestone_hardware_child_devices.milestone_hardware_id, ''),
|
|
COALESCE(milestone_hardware_child_devices.milestone_device_id, ''),
|
|
COALESCE(milestone_hardware_child_devices.device_type, ''),
|
|
COALESCE(milestone_hardware_child_devices.name, ''),
|
|
COALESCE(milestone_hardware_child_devices.display_name, ''),
|
|
COALESCE(milestone_hardware_child_devices.short_name, ''),
|
|
COALESCE(milestone_hardware_child_devices.enabled, true),
|
|
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 {
|
|
logError("Milestone-Child-Devices konnten nicht geladen werden", 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 {
|
|
logError("Milestone-Child-Devices konnten nicht gelesen werden", 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 {
|
|
logError("Milestone-Child-Devices konnten nicht vollständig gelesen werden", 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,
|
|
phone_number,
|
|
computer_name,
|
|
device_category,
|
|
milestone_port,
|
|
location,
|
|
loan_status,
|
|
loaned_to_user_id,
|
|
loaned_until,
|
|
is_bao_device,
|
|
comment,
|
|
firmware_version
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NULLIF($13, '')::UUID, NULLIF($14, '')::DATE, $15, $16, $17)
|
|
RETURNING
|
|
id,
|
|
inventory_number,
|
|
manufacturer,
|
|
model,
|
|
serial_number,
|
|
mac_address,
|
|
COALESCE(ip_address, ''),
|
|
COALESCE(phone_number, ''),
|
|
COALESCE(computer_name, ''),
|
|
COALESCE(device_category, ''),
|
|
COALESCE(milestone_port, ''),
|
|
location,
|
|
loan_status,
|
|
COALESCE(loaned_to_user_id::TEXT, ''),
|
|
'',
|
|
COALESCE(loaned_until::TEXT, ''),
|
|
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.PhoneNumber,
|
|
input.ComputerName,
|
|
input.DeviceCategory,
|
|
input.MilestonePort,
|
|
input.Location,
|
|
input.LoanStatus,
|
|
input.LoanedToUserID,
|
|
input.LoanedUntil,
|
|
input.IsBaoDevice,
|
|
input.Comment,
|
|
input.FirmwareVersion,
|
|
).Scan(
|
|
&device.ID,
|
|
&device.InventoryNumber,
|
|
&device.Manufacturer,
|
|
&device.Model,
|
|
&device.SerialNumber,
|
|
&device.MacAddress,
|
|
&device.IPAddress,
|
|
&device.PhoneNumber,
|
|
&device.ComputerName,
|
|
&device.DeviceCategory,
|
|
&device.MilestonePort,
|
|
&device.Location,
|
|
&device.LoanStatus,
|
|
&device.LoanedToUserID,
|
|
&device.LoanedToDisplayName,
|
|
&device.LoanedUntil,
|
|
&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 {
|
|
logError("Device history state konnte nicht geladen werden", err, LogFields{
|
|
"deviceId": deviceID,
|
|
})
|
|
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,
|
|
phone_number = $8,
|
|
computer_name = $9,
|
|
device_category = $10,
|
|
milestone_port = $11,
|
|
location = $12,
|
|
loan_status = $13,
|
|
loaned_to_user_id = NULLIF($14, '')::UUID,
|
|
loaned_until = NULLIF($15, '')::DATE,
|
|
is_bao_device = $16,
|
|
comment = $17,
|
|
firmware_version = $18,
|
|
milestone_display_name = $19,
|
|
milestone_enabled = $20,
|
|
milestone_synced_at = CASE
|
|
WHEN COALESCE(milestone_hardware_id, '') <> ''
|
|
AND (
|
|
COALESCE(milestone_display_name, '') <> $19
|
|
OR COALESCE(ip_address, '') <> $7
|
|
OR COALESCE(milestone_port, '') <> $11
|
|
OR milestone_enabled <> $20
|
|
)
|
|
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(phone_number, ''),
|
|
COALESCE(computer_name, ''),
|
|
COALESCE(device_category, ''),
|
|
COALESCE(milestone_port, ''),
|
|
location,
|
|
loan_status,
|
|
COALESCE(loaned_to_user_id::TEXT, ''),
|
|
'',
|
|
COALESCE(loaned_until::TEXT, ''),
|
|
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.PhoneNumber,
|
|
input.ComputerName,
|
|
input.DeviceCategory,
|
|
input.MilestonePort,
|
|
input.Location,
|
|
input.LoanStatus,
|
|
input.LoanedToUserID,
|
|
input.LoanedUntil,
|
|
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.PhoneNumber,
|
|
&device.ComputerName,
|
|
&device.DeviceCategory,
|
|
&device.MilestonePort,
|
|
&device.Location,
|
|
&device.LoanStatus,
|
|
&device.LoanedToUserID,
|
|
&device.LoanedToDisplayName,
|
|
&device.LoanedUntil,
|
|
&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 (s *Server) handleDeleteDevice(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
|
|
}
|
|
|
|
tx, err := s.db.Begin(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Gerät konnte nicht gelöscht werden")
|
|
return
|
|
}
|
|
defer tx.Rollback(r.Context())
|
|
|
|
var device Device
|
|
|
|
err = tx.QueryRow(
|
|
r.Context(),
|
|
`
|
|
SELECT
|
|
id,
|
|
COALESCE(inventory_number, ''),
|
|
COALESCE(manufacturer, ''),
|
|
COALESCE(model, ''),
|
|
COALESCE(milestone_recording_server_id, ''),
|
|
COALESCE(milestone_hardware_id, ''),
|
|
COALESCE(milestone_display_name, '')
|
|
FROM devices
|
|
WHERE id = $1
|
|
FOR UPDATE
|
|
`,
|
|
deviceID,
|
|
).Scan(
|
|
&device.ID,
|
|
&device.InventoryNumber,
|
|
&device.Manufacturer,
|
|
&device.Model,
|
|
&device.MilestoneRecordingServerID,
|
|
&device.MilestoneHardwareID,
|
|
&device.MilestoneDisplayName,
|
|
)
|
|
|
|
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 gelöscht werden")
|
|
return
|
|
}
|
|
|
|
if strings.TrimSpace(device.MilestoneHardwareID) != "" ||
|
|
strings.TrimSpace(device.MilestoneRecordingServerID) != "" {
|
|
writeError(w, http.StatusConflict, "Milestone-Geräte können nicht gelöscht werden")
|
|
return
|
|
}
|
|
|
|
if _, err := tx.Exec(r.Context(), `DELETE FROM devices WHERE id = $1`, device.ID); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Gerät konnte nicht gelöscht werden")
|
|
return
|
|
}
|
|
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Gerät konnte nicht gelöscht werden")
|
|
return
|
|
}
|
|
|
|
if err := s.publishDeviceChangeEvent(
|
|
r.Context(),
|
|
user.ID,
|
|
device.ID,
|
|
"device.deleted",
|
|
"Gerät gelöscht",
|
|
formatText(
|
|
"Gerät %s wurde gelöscht.",
|
|
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.deleted",
|
|
})
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"deletedDeviceId": device.ID,
|
|
})
|
|
}
|
|
|
|
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.PhoneNumber = strings.TrimSpace(input.PhoneNumber)
|
|
input.ComputerName = strings.TrimSpace(input.ComputerName)
|
|
input.DeviceCategory = strings.TrimSpace(input.DeviceCategory)
|
|
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.LoanedToUserID = strings.TrimSpace(input.LoanedToUserID)
|
|
input.LoanedUntil = strings.TrimSpace(input.LoanedUntil)
|
|
input.Comment = strings.TrimSpace(input.Comment)
|
|
|
|
if input.LoanStatus == "" {
|
|
input.LoanStatus = "Verfügbar"
|
|
}
|
|
|
|
if !strings.EqualFold(input.LoanStatus, "Ausgeliehen") {
|
|
input.LoanedToUserID = ""
|
|
input.LoanedUntil = ""
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|