diff --git a/backend/.env b/backend/.env index 8b9a39d..8fca91b 100644 --- a/backend/.env +++ b/backend/.env @@ -17,4 +17,4 @@ ADMIN_GROUP=admin ADMIN_RIGHTS=admin,users:read,users:write ADMIN_TEAM_NAME=Administration ADMIN_TEAM_INITIAL=A -ADMIN_TEAM_HREF=# \ No newline at end of file +ADMIN_TEAM_HREF=#administration \ No newline at end of file diff --git a/backend/address_reverse.go b/backend/address_reverse.go new file mode 100644 index 0000000..4190767 --- /dev/null +++ b/backend/address_reverse.go @@ -0,0 +1,192 @@ +// backend\address_reverse.go + +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +type reverseAddressSuggestion struct { + ID string `json:"id"` + Label string `json:"label"` + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + Type string `json:"type,omitempty"` + GeoJSON json.RawMessage `json:"geojson,omitempty"` +} + +type nominatimReverseAddress struct { + HouseNumber string `json:"house_number"` + Road string `json:"road"` + Footway string `json:"footway"` + Pedestrian string `json:"pedestrian"` + Path string `json:"path"` + Suburb string `json:"suburb"` + Quarter string `json:"quarter"` + CityDistrict string `json:"city_district"` + Borough string `json:"borough"` + City string `json:"city"` + Town string `json:"town"` + Village string `json:"village"` + Municipality string `json:"municipality"` + County string `json:"county"` + State string `json:"state"` + Postcode string `json:"postcode"` + Country string `json:"country"` +} + +type nominatimReverseResponse struct { + PlaceID int64 `json:"place_id"` + OSMType string `json:"osm_type"` + OSMID int64 `json:"osm_id"` + DisplayName string `json:"display_name"` + Lat string `json:"lat"` + Lon string `json:"lon"` + Type string `json:"type"` + Address nominatimReverseAddress `json:"address"` + GeoJSON json.RawMessage `json:"geojson,omitempty"` +} + +func appendUnique(parts []string, value string) []string { + value = strings.TrimSpace(value) + + if value == "" { + return parts + } + + for _, part := range parts { + if strings.EqualFold(part, value) { + return parts + } + } + + return append(parts, value) +} + +func formatReverseAddressLabel(result nominatimReverseResponse) string { + address := result.Address + + street := firstNonEmpty( + address.Road, + address.Footway, + address.Pedestrian, + address.Path, + ) + + streetLine := street + + if street != "" && strings.TrimSpace(address.HouseNumber) != "" { + streetLine = fmt.Sprintf("%s %s", street, strings.TrimSpace(address.HouseNumber)) + } + + city := firstNonEmpty( + address.City, + address.Town, + address.Village, + address.Municipality, + ) + + postcodeCity := city + + if strings.TrimSpace(address.Postcode) != "" && city != "" { + postcodeCity = fmt.Sprintf("%s %s", strings.TrimSpace(address.Postcode), city) + } + + parts := []string{} + + parts = appendUnique(parts, streetLine) + parts = appendUnique(parts, postcodeCity) + parts = appendUnique(parts, address.State) + parts = appendUnique(parts, address.Country) + + if len(parts) > 0 { + return strings.Join(parts, ", ") + } + + return strings.TrimSpace(result.DisplayName) +} + +func (s *Server) handleAddressReverse(w http.ResponseWriter, r *http.Request) { + latRaw := strings.TrimSpace(r.URL.Query().Get("lat")) + lonRaw := strings.TrimSpace(r.URL.Query().Get("lon")) + + lat, err := strconv.ParseFloat(latRaw, 64) + if err != nil || lat < -90 || lat > 90 { + writeError(w, http.StatusBadRequest, "Ungültige Latitude") + return + } + + lon, err := strconv.ParseFloat(lonRaw, 64) + if err != nil || lon < -180 || lon > 180 { + writeError(w, http.StatusBadRequest, "Ungültige Longitude") + return + } + + params := url.Values{} + params.Set("format", "jsonv2") + params.Set("addressdetails", "1") + params.Set("polygon_geojson", "1") + params.Set("lat", strconv.FormatFloat(lat, 'f', 7, 64)) + params.Set("lon", strconv.FormatFloat(lon, 'f', 7, 64)) + params.Set("zoom", "18") + params.Set("accept-language", "de") + + reverseURL := fmt.Sprintf( + "https://nominatim.openstreetmap.org/reverse?%s", + params.Encode(), + ) + + request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, reverseURL, nil) + if err != nil { + writeError(w, http.StatusInternalServerError, "Adresse konnte nicht vorbereitet werden") + return + } + + request.Header.Set("User-Agent", "TEG/1.0 address reverse lookup") + + client := &http.Client{ + Timeout: 8 * time.Second, + } + + response, err := client.Do(request) + if err != nil { + writeError(w, http.StatusBadGateway, "Adresse konnte nicht ermittelt werden") + return + } + defer response.Body.Close() + + if response.StatusCode < 200 || response.StatusCode >= 300 { + writeError(w, http.StatusBadGateway, "Adresse konnte nicht ermittelt werden") + return + } + + var result nominatimReverseResponse + + if err := json.NewDecoder(response.Body).Decode(&result); err != nil { + writeError(w, http.StatusBadGateway, "Adresse konnte nicht gelesen werden") + return + } + + label := formatReverseAddressLabel(result) + if label == "" { + writeError(w, http.StatusNotFound, "Für diese Position wurde keine Adresse gefunden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "suggestion": reverseAddressSuggestion{ + ID: fmt.Sprintf("reverse-%d-%d", result.PlaceID, time.Now().UnixNano()), + Label: label, + Lat: lat, + Lon: lon, + Type: result.Type, + GeoJSON: result.GeoJSON, + }, + }) +} diff --git a/backend/address_search.go b/backend/address_search.go index e295988..e4a60b8 100644 --- a/backend/address_search.go +++ b/backend/address_search.go @@ -49,11 +49,12 @@ type photonGeometry struct { } type nominatimSearchResult struct { - PlaceID int64 `json:"place_id"` - DisplayName string `json:"display_name"` - Lat string `json:"lat"` - Lon string `json:"lon"` - Type string `json:"type"` + PlaceID int64 `json:"place_id"` + DisplayName string `json:"display_name"` + Lat string `json:"lat"` + Lon string `json:"lon"` + Type string `json:"type"` + Address nominatimReverseAddress `json:"address"` } func (s *Server) handleAddressSearch(w http.ResponseWriter, r *http.Request) { @@ -234,7 +235,11 @@ func (s *Server) handleNominatimAddressSearch( suggestions := make([]AddressSuggestion, 0, len(results)) for _, result := range results { - label := strings.TrimSpace(result.DisplayName) + label := formatReverseAddressLabel(nominatimReverseResponse{ + DisplayName: result.DisplayName, + Address: result.Address, + }) + if label == "" { continue } diff --git a/backend/admin.go b/backend/admin.go index d5b02a8..24010d0 100644 --- a/backend/admin.go +++ b/backend/admin.go @@ -72,6 +72,10 @@ func normalizeAdminUserInput(input *AdminUserRequest) { input.TeamIDs = cleanStringList(input.TeamIDs) } +func ensureAdminUserLookupValues(ctx context.Context, tx pgx.Tx, unit string) error { + return ensureOptionalDeviceLookupValue(ctx, tx, "user_units", unit) +} + func normalizeAdminTeamInput(input *AdminTeamRequest) { input.Name = strings.TrimSpace(input.Name) input.Initial = strings.TrimSpace(input.Initial) @@ -130,7 +134,7 @@ func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) { defer rows.Close() users := []AdminUser{} - userByID := map[string]*AdminUser{} + userIndexByID := map[string]int{} for rows.Next() { var user AdminUser @@ -152,8 +156,14 @@ func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) { } user.Teams = []AdminTeam{} + users = append(users, user) - userByID[user.ID] = &users[len(users)-1] + userIndexByID[user.ID] = len(users) - 1 + } + + if err := rows.Err(); err != nil { + writeError(w, http.StatusInternalServerError, "Benutzer konnten nicht vollständig gelesen werden") + return } teamRows, err := s.db.Query( @@ -196,9 +206,17 @@ func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) { return } - if user := userByID[userID]; user != nil { - user.Teams = append(user.Teams, team) + userIndex, ok := userIndexByID[userID] + if !ok { + continue } + + users[userIndex].Teams = append(users[userIndex].Teams, team) + } + + if err := teamRows.Err(); err != nil { + writeError(w, http.StatusInternalServerError, "Team-Zuordnungen konnten nicht vollständig gelesen werden") + return } writeJSON(w, http.StatusOK, map[string]any{ @@ -239,6 +257,11 @@ func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) { } defer tx.Rollback(r.Context()) + if err := ensureAdminUserLookupValues(r.Context(), tx, input.Unit); err != nil { + writeError(w, http.StatusInternalServerError, "Einheit konnte nicht gespeichert werden") + return + } + var userID string err = tx.QueryRow( @@ -289,6 +312,10 @@ func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) { return } + if createdUser, err := s.getUserByID(r.Context(), userID); err == nil { + s.publishUserProfileEvent("user.created", createdUser) + } + writeJSON(w, http.StatusCreated, map[string]any{ "ok": true, }) @@ -323,6 +350,11 @@ func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) { } defer tx.Rollback(r.Context()) + if err := ensureAdminUserLookupValues(r.Context(), tx, input.Unit); err != nil { + writeError(w, http.StatusInternalServerError, "Einheit konnte nicht gespeichert werden") + return + } + if input.Password != "" { passwordHash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost) if err != nil { @@ -403,6 +435,10 @@ func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) { return } + if updatedUser, err := s.getUserByID(r.Context(), userID); err == nil { + s.publishUserProfileEvent("user.updated", updatedUser) + } + writeJSON(w, http.StatusOK, map[string]any{ "ok": true, }) diff --git a/backend/admin_milestone.go b/backend/admin_milestone.go index 9e14bd1..8d3f22c 100644 --- a/backend/admin_milestone.go +++ b/backend/admin_milestone.go @@ -149,6 +149,7 @@ type milestoneHardwareDevice struct { Manufacturer string MacAddress string IPAddress string + Port string DeviceModelName string SerialNumber string FirmwareVersion string @@ -830,7 +831,7 @@ func (s *Server) requestMilestoneHardwareDevices( enabled = !*hardware.Disabled } - ipAddress := extractMilestoneHardwareIPAddress(hardware.Address) + ipAddress, port := extractMilestoneHardwareAddressParts(hardware.Address) result = append(result, milestoneHardwareDevice{ HardwareID: hardwareID, @@ -840,6 +841,7 @@ func (s *Server) requestMilestoneHardwareDevices( Manufacturer: manufacturer, MacAddress: strings.TrimSpace(settings.MacAddress), IPAddress: ipAddress, + Port: port, DeviceModelName: model, SerialNumber: strings.TrimSpace(settings.SerialNumber), FirmwareVersion: strings.TrimSpace(settings.FirmwareVersion), @@ -857,25 +859,35 @@ func (s *Server) requestMilestoneHardwareDevices( } func extractMilestoneHardwareIPAddress(value string) string { + host, _ := extractMilestoneHardwareAddressParts(value) + return host +} + +func extractMilestoneHardwarePort(value string) string { + _, port := extractMilestoneHardwareAddressParts(value) + return port +} + +func extractMilestoneHardwareAddressParts(value string) (string, string) { value = strings.TrimSpace(value) if value == "" { - return "" + return "", "" } parsedURL, err := url.Parse(value) if err == nil && parsedURL.Hostname() != "" { - return parsedURL.Hostname() + return strings.TrimSpace(parsedURL.Hostname()), strings.TrimSpace(parsedURL.Port()) } value = strings.TrimPrefix(value, "http://") value = strings.TrimPrefix(value, "https://") value = strings.TrimRight(value, "/") - if host, _, found := strings.Cut(value, ":"); found { - return strings.TrimSpace(host) + if host, port, found := strings.Cut(value, ":"); found { + return strings.TrimSpace(host), strings.TrimSpace(port) } - return strings.TrimSpace(value) + return strings.TrimSpace(value), "" } func (s *Server) requestMilestoneHardware( diff --git a/backend/camera_firmware.go b/backend/camera_firmware.go index eaa0ea5..60fc09d 100644 --- a/backend/camera_firmware.go +++ b/backend/camera_firmware.go @@ -1,4 +1,4 @@ -// backend/camera_firmware.go +// backend\camera_firmware.go package main diff --git a/backend/cors.go b/backend/cors.go index 243e70f..6768348 100644 --- a/backend/cors.go +++ b/backend/cors.go @@ -12,7 +12,7 @@ func (s *Server) cors(next http.Handler) http.Handler { w.Header().Set("Access-Control-Allow-Origin", s.frontendOrigin) w.Header().Set("Access-Control-Allow-Credentials", "true") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") } if r.Method == http.MethodOptions { diff --git a/backend/device_events.go b/backend/device_events.go index 99049bd..edac0f8 100644 --- a/backend/device_events.go +++ b/backend/device_events.go @@ -50,68 +50,19 @@ func (s *Server) publishDeviceChangeEvent( return err } - var notification Notification - - err = s.db.QueryRow( + if err := s.createAndPublishNotification( ctx, - ` - INSERT INTO notifications ( - user_id, - type, - title, - message, - visible, - entity_type, - entity_id, - data - ) - VALUES ( - $1, - $2, - $3, - $4, - false, - 'device', - $5, - $6 - ) - RETURNING - id::TEXT, - user_id::TEXT, - type, - title, - message, - entity_type, - COALESCE(entity_id::TEXT, ''), - data, - visible, - read_at, - created_at - `, userID, notificationType, title, message, + "device", deviceID, dataJSON, - ).Scan( - ¬ification.ID, - ¬ification.UserID, - ¬ification.Type, - ¬ification.Title, - ¬ification.Message, - ¬ification.EntityType, - ¬ification.EntityID, - ¬ification.Data, - ¬ification.Visible, - ¬ification.ReadAt, - ¬ification.CreatedAt, - ) - if err != nil { + true, + ); err != nil { return err } - - notificationsBroker.publish(notification) } return rows.Err() diff --git a/backend/devices.go b/backend/devices.go index 5e2aded..fed1ecc 100644 --- a/backend/devices.go +++ b/backend/devices.go @@ -1,4 +1,4 @@ -// backend/devices.go +// backend\devices.go package main @@ -21,6 +21,7 @@ type Device struct { 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"` @@ -69,8 +70,10 @@ type CreateDeviceRequest struct { 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"` @@ -80,6 +83,10 @@ type CreateDeviceRequest struct { 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) { @@ -105,6 +112,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) { serial_number, mac_address, COALESCE(ip_address, ''), + COALESCE(milestone_port, ''), location, loan_status, is_bao_device, @@ -142,6 +150,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) { &device.SerialNumber, &device.MacAddress, &device.IPAddress, + &device.MilestonePort, &device.Location, &device.LoanStatus, &device.IsBaoDevice, @@ -270,13 +279,14 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) { 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) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id, inventory_number, @@ -285,6 +295,7 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) { serial_number, mac_address, COALESCE(ip_address, ''), + COALESCE(milestone_port, ''), location, loan_status, is_bao_device, @@ -304,6 +315,7 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) { input.SerialNumber, input.MacAddress, input.IPAddress, + input.MilestonePort, input.Location, input.LoanStatus, input.IsBaoDevice, @@ -317,6 +329,7 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) { &device.SerialNumber, &device.MacAddress, &device.IPAddress, + &device.MilestonePort, &device.Location, &device.LoanStatus, &device.IsBaoDevice, @@ -449,6 +462,11 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) { return } + nextMilestoneEnabled := oldDevice.MilestoneEnabled + if input.MilestoneEnabled != nil { + nextMilestoneEnabled = *input.MilestoneEnabled + } + var device Device err = tx.QueryRow( @@ -462,12 +480,25 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) { serial_number = $5, mac_address = $6, ip_address = $7, - location = $8, - loan_status = $9, - is_bao_device = $10, - comment = $11, - firmware_version = $12, - milestone_display_name = $13, + 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 @@ -478,6 +509,7 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) { serial_number, mac_address, COALESCE(ip_address, ''), + COALESCE(milestone_port, ''), location, loan_status, is_bao_device, @@ -498,12 +530,14 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) { 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, @@ -512,6 +546,7 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) { &device.SerialNumber, &device.MacAddress, &device.IPAddress, + &device.MilestonePort, &device.Location, &device.LoanStatus, &device.IsBaoDevice, @@ -634,6 +669,7 @@ func normalizeDeviceInput(input *CreateDeviceRequest) { 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) diff --git a/backend/feedback.go b/backend/feedback.go index 474ca0c..d4a3b4c 100644 --- a/backend/feedback.go +++ b/backend/feedback.go @@ -164,7 +164,7 @@ func (s *Server) handleCreateFeedback(w http.ResponseWriter, r *http.Request) { "Neues Feedback", formatText("Neues Feedback von %s.", displayFeedbackUserName(feedback)), feedback.ID, - true, + false, map[string]any{ "feedbackId": feedback.ID, "userId": feedback.UserID, @@ -314,7 +314,7 @@ func (s *Server) handleDeleteFeedback(w http.ResponseWriter, r *http.Request) { "Feedback gelöscht", formatText("Feedback von %s wurde gelöscht.", displayFeedbackUserName(deleted)), deleted.ID, - false, + true, map[string]any{ "feedbackId": deleted.ID, "userId": deleted.UserID, @@ -375,70 +375,19 @@ func (s *Server) notifyAdministratorsAboutFeedback( return err } - var notification Notification - - err = s.db.QueryRow( + if err := s.createAndPublishNotification( ctx, - ` - INSERT INTO notifications ( - user_id, - type, - title, - message, - visible, - entity_type, - entity_id, - data - ) - VALUES ( - $1, - $2, - $3, - $4, - true, - 'feedback', - $5, - $6, - $7 - ) - RETURNING - id::TEXT, - user_id::TEXT, - type, - title, - message, - entity_type, - COALESCE(entity_id::TEXT, ''), - data, - visible, - read_at, - created_at - `, adminUserID, notificationType, title, message, - visible, + "feedback", feedbackID, dataJSON, - ).Scan( - ¬ification.ID, - ¬ification.UserID, - ¬ification.Type, - ¬ification.Title, - ¬ification.Message, - ¬ification.EntityType, - ¬ification.EntityID, - ¬ification.Data, - ¬ification.Visible, - ¬ification.ReadAt, - ¬ification.CreatedAt, - ) - if err != nil { + visible, + ); err != nil { return err } - - notificationsBroker.publish(notification) } return adminRows.Err() diff --git a/backend/history.go b/backend/history.go index f60f7e1..6639836 100644 --- a/backend/history.go +++ b/backend/history.go @@ -21,6 +21,8 @@ type DeviceHistoryEntry struct { 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 { @@ -42,6 +44,12 @@ func (s *Server) handleListDeviceHistory(w http.ResponseWriter, r *http.Request) return } + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + rows, err := s.db.Query( r.Context(), ` @@ -52,7 +60,8 @@ func (s *Server) handleListDeviceHistory(w http.ResponseWriter, r *http.Request) COALESCE(NULLIF(u.display_name, ''), NULLIF(u.username, ''), u.email, 'Unbekannt'), h.action, h.changes, - h.created_at + 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 @@ -81,6 +90,7 @@ func (s *Server) handleListDeviceHistory(w http.ResponseWriter, r *http.Request) &entry.Action, &changesRaw, &entry.CreatedAt, + &entry.UpdatedAt, ); err != nil { writeError(w, http.StatusInternalServerError, "Could not read device history") return @@ -93,6 +103,11 @@ func (s *Server) handleListDeviceHistory(w http.ResponseWriter, r *http.Request) } } + entry.CanEdit = + entry.Action == "journal" && + entry.UserID == user.ID && + time.Since(entry.CreatedAt) <= 10*time.Minute + history = append(history, entry) } @@ -239,6 +254,13 @@ func getDeviceForHistory(ctx context.Context, tx pgx.Tx, deviceID string) (Devic model, serial_number, mac_address, + COALESCE(ip_address, ''), + COALESCE(firmware_version, ''), + COALESCE(milestone_recording_server_id, ''), + COALESCE(milestone_hardware_id, ''), + COALESCE(milestone_display_name, ''), + milestone_enabled, + milestone_synced_at, location, loan_status, is_bao_device, @@ -257,6 +279,13 @@ func getDeviceForHistory(ctx context.Context, tx pgx.Tx, deviceID string) (Devic &device.Model, &device.SerialNumber, &device.MacAddress, + &device.IPAddress, + &device.FirmwareVersion, + &device.MilestoneRecordingServerID, + &device.MilestoneHardwareID, + &device.MilestoneDisplayName, + &device.MilestoneEnabled, + &device.MilestoneSyncedAt, &device.Location, &device.LoanStatus, &device.IsBaoDevice, @@ -304,6 +333,190 @@ func getDeviceForHistory(ctx context.Context, tx pgx.Tx, deviceID string) (Devic 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" @@ -357,6 +570,17 @@ func buildDeviceUpdateHistoryChanges( 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) + 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 { diff --git a/backend/milestone_api.go b/backend/milestone_api.go new file mode 100644 index 0000000..fa35bf0 --- /dev/null +++ b/backend/milestone_api.go @@ -0,0 +1,614 @@ +// backend\milestone_api.go + +package main + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/jackc/pgx/v5" +) + +type milestoneRuntimeConfig struct { + Host string + AccessToken string + TokenType string + SkipTLSVerify bool + TokenExpiresAt *time.Time +} + +type milestoneHardwarePatchDevice struct { + ID string + InventoryNumber string + MilestoneHardwareID string + MilestoneDisplayName string + IPAddress string + MilestonePort string + MilestoneEnabled bool +} + +type updateMilestoneHardwareRequest struct { + Name *string `json:"name"` + DisplayName *string `json:"displayName"` + MilestoneDisplayName *string `json:"milestoneDisplayName"` + IPAddress *string `json:"ipAddress"` + Address *string `json:"address"` + Port *string `json:"port"` + MilestonePort *string `json:"milestonePort"` + Enabled *bool `json:"enabled"` +} + +func (s *Server) getMilestoneHardwarePatchDevice( + ctx context.Context, + deviceID string, +) (milestoneHardwarePatchDevice, error) { + var device milestoneHardwarePatchDevice + + err := s.db.QueryRow( + ctx, + ` + SELECT + id::TEXT, + inventory_number, + COALESCE(milestone_hardware_id, ''), + COALESCE(milestone_display_name, ''), + COALESCE(ip_address, ''), + COALESCE(milestone_port, ''), + milestone_enabled + FROM devices + WHERE id = $1 + LIMIT 1 + `, + deviceID, + ).Scan( + &device.ID, + &device.InventoryNumber, + &device.MilestoneHardwareID, + &device.MilestoneDisplayName, + &device.IPAddress, + &device.MilestonePort, + &device.MilestoneEnabled, + ) + + return device, err +} + +func (s *Server) handleUpdateMilestoneHardware(w http.ResponseWriter, r *http.Request) { + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + deviceID := strings.TrimSpace(r.PathValue("id")) + if deviceID == "" { + writeError(w, http.StatusBadRequest, "Geräte-ID fehlt") + return + } + + var input updateMilestoneHardwareRequest + + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Invalid JSON") + return + } + + if input.Name == nil && + input.DisplayName == nil && + input.MilestoneDisplayName == nil && + input.IPAddress == nil && + input.Address == nil && + input.Port == nil && + input.MilestonePort == nil && + input.Enabled == nil { + writeError(w, http.StatusBadRequest, "Keine Milestone-Felder zum Aktualisieren übergeben") + return + } + + device, err := s.getMilestoneHardwarePatchDevice(r.Context(), 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 + } + + if strings.TrimSpace(device.MilestoneHardwareID) == "" { + writeError(w, http.StatusBadRequest, "Dieses Gerät ist kein Milestone-Gerät") + return + } + + nextDisplayName := strings.TrimSpace(device.MilestoneDisplayName) + nextIPAddress := strings.TrimSpace(device.IPAddress) + nextPort := strings.TrimSpace(device.MilestonePort) + nextEnabled := device.MilestoneEnabled + + payload := map[string]any{} + changes := []DeviceHistoryChange{} + + requestedDisplayName := input.Name + if requestedDisplayName == nil { + requestedDisplayName = input.DisplayName + } + if requestedDisplayName == nil { + requestedDisplayName = input.MilestoneDisplayName + } + + if requestedDisplayName != nil { + nextDisplayName = strings.TrimSpace(*requestedDisplayName) + + // Wenn der Client einen Namen sendet, immer an Milestone weitergeben. + payload["name"] = nextDisplayName + + if nextDisplayName != strings.TrimSpace(device.MilestoneDisplayName) { + changes = appendHistoryChange( + changes, + "milestoneDisplayName", + "Milestone-Anzeigename", + device.MilestoneDisplayName, + nextDisplayName, + ) + } + } + + requestedAddress := input.Address + if requestedAddress == nil { + requestedAddress = input.IPAddress + } + + requestedPort := input.Port + if requestedPort == nil { + requestedPort = input.MilestonePort + } + + if requestedAddress != nil || requestedPort != nil { + addressValue := device.IPAddress + if requestedAddress != nil { + addressValue = *requestedAddress + } + + portValue := device.MilestonePort + if requestedPort != nil { + portValue = *requestedPort + } + + nextAddress := normalizeMilestoneHardwareAddress(addressValue, portValue) + nextIPAddress, parsedPort := extractMilestoneHardwareAddressParts(nextAddress) + nextPort = normalizeMilestonePort(portValue) + if nextPort == "" { + nextPort = parsedPort + } + + if nextIPAddress == "" { + nextIPAddress = strings.TrimSpace(addressValue) + } + + // Wenn der Client Adresse oder Port sendet, immer an Milestone weitergeben. + payload["address"] = nextAddress + + if nextIPAddress != strings.TrimSpace(device.IPAddress) { + changes = appendHistoryChange( + changes, + "ipAddress", + "IP-Adresse", + device.IPAddress, + nextIPAddress, + ) + } + + if nextPort != strings.TrimSpace(device.MilestonePort) { + changes = appendHistoryChange( + changes, + "milestonePort", + "Milestone-Port", + device.MilestonePort, + nextPort, + ) + } + } + + if input.Enabled != nil { + nextEnabled = *input.Enabled + + // Wenn der Client den Status sendet, immer an Milestone weitergeben. + payload["enabled"] = nextEnabled + + if nextEnabled != device.MilestoneEnabled { + changes = appendHistoryChange( + changes, + "milestoneEnabled", + "Milestone-Status", + formatHistoryBool(device.MilestoneEnabled), + formatHistoryBool(nextEnabled), + ) + } + } + + if len(payload) == 0 { + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "id": device.ID, + "milestoneDisplayName": device.MilestoneDisplayName, + "ipAddress": device.IPAddress, + "milestonePort": device.MilestonePort, + "milestoneEnabled": device.MilestoneEnabled, + }) + return + } + + config, err := s.getMilestoneRuntimeConfig(r.Context()) + if errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusBadRequest, "Milestone-Einstellungen sind noch nicht hinterlegt") + return + } + + if err != nil { + s.logAndWriteUserError( + w, + r, + user, + http.StatusBadGateway, + "milestone_hardware", + "load_or_refresh_token", + "Milestone-Token konnte nicht geladen oder erneuert werden", + err, + LogFields{ + "deviceId": device.ID, + "inventoryNumber": device.InventoryNumber, + "milestoneHardwareId": device.MilestoneHardwareID, + }, + ) + return + } + + if strings.TrimSpace(config.Host) == "" { + writeError(w, http.StatusBadRequest, "Milestone Host fehlt") + return + } + + if strings.TrimSpace(config.AccessToken) == "" { + writeError(w, http.StatusBadRequest, "Milestone-Token fehlt und konnte nicht automatisch erneuert werden") + return + } + + if err := patchMilestoneHardware( + r.Context(), + config, + device.MilestoneHardwareID, + payload, + ); err != nil { + s.logAndWriteUserError( + w, + r, + user, + http.StatusBadGateway, + "milestone_hardware", + "patch_hardware", + "Milestone-Gerät konnte nicht aktualisiert werden", + err, + LogFields{ + "deviceId": device.ID, + "inventoryNumber": device.InventoryNumber, + "milestoneHardwareId": device.MilestoneHardwareID, + "payload": payload, + }, + ) + return + } + + tx, err := s.db.Begin(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "Milestone-Gerät konnte lokal nicht aktualisiert werden") + return + } + defer tx.Rollback(r.Context()) + + _, err = tx.Exec( + r.Context(), + ` + UPDATE devices + SET + milestone_display_name = $2, + ip_address = $3, + milestone_port = $4, + milestone_enabled = $5, + milestone_synced_at = now(), + updated_at = now() + WHERE id = $1 + `, + device.ID, + nextDisplayName, + nextIPAddress, + nextPort, + nextEnabled, + ) + if err != nil { + writeError(w, http.StatusInternalServerError, "Milestone-Gerät konnte lokal nicht aktualisiert werden") + return + } + + if len(changes) > 0 { + if err := recordDeviceHistory(r.Context(), tx, device.ID, user, "updated", changes); err != nil { + writeError(w, http.StatusInternalServerError, "Geräteverlauf konnte nicht gespeichert werden") + return + } + } + + if err := tx.Commit(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "Milestone-Gerät konnte lokal nicht aktualisiert werden") + return + } + + if err := s.publishDeviceChangeEvent( + r.Context(), + user.ID, + device.ID, + "device.milestone.hardware", + "Milestone-Gerät aktualisiert", + formatText("Gerät %s wurde in Milestone aktualisiert.", device.InventoryNumber), + LogFields{ + "inventoryNumber": device.InventoryNumber, + "milestoneHardwareId": device.MilestoneHardwareID, + "payload": payload, + }, + ); err != nil { + logError("Device-SSE-Event konnte nicht gesendet werden", err, LogFields{ + "deviceId": device.ID, + "type": "device.milestone.hardware", + }) + } + + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "id": device.ID, + "milestoneDisplayName": nextDisplayName, + "ipAddress": nextIPAddress, + "milestonePort": nextPort, + "milestoneEnabled": nextEnabled, + }) +} + +func normalizeMilestoneHardwareAddress(value string, port string) string { + value = strings.TrimSpace(value) + port = normalizeMilestonePort(port) + + if value == "" || value == "-" { + return "" + } + + if !strings.HasPrefix(value, "http://") && !strings.HasPrefix(value, "https://") { + value = "http://" + strings.TrimRight(value, "/") + } + + parsedURL, err := url.Parse(value) + if err != nil || parsedURL.Hostname() == "" { + value = strings.TrimRight(value, "/") + if port != "" && !strings.Contains(value, ":") { + value += ":" + port + } + + return value + "/" + } + + scheme := strings.TrimSpace(parsedURL.Scheme) + if scheme == "" { + scheme = "http" + } + + host := parsedURL.Hostname() + if port == "" { + port = parsedURL.Port() + } + + hostPort := host + if port != "" { + hostPort += ":" + port + } + + return scheme + "://" + hostPort + "/" +} + +func normalizeMilestonePort(value string) string { + value = strings.TrimSpace(value) + value = strings.TrimPrefix(value, ":") + + if value == "-" { + return "" + } + + return value +} + +const milestoneTokenRefreshBeforeExpiry = 2 * time.Minute + +func (s *Server) getMilestoneRuntimeConfig( + ctx context.Context, +) (milestoneRuntimeConfig, error) { + config, err := s.loadMilestoneRuntimeConfig(ctx) + if err != nil { + return config, err + } + + if shouldRefreshMilestoneToken(config) { + if err := s.refreshMilestoneRuntimeToken(ctx); err != nil { + return config, err + } + + return s.loadMilestoneRuntimeConfig(ctx) + } + + return config, nil +} + +func shouldRefreshMilestoneToken(config milestoneRuntimeConfig) bool { + if strings.TrimSpace(config.AccessToken) == "" { + return true + } + + if config.TokenExpiresAt == nil { + return true + } + + return time.Now().After(config.TokenExpiresAt.Add(-milestoneTokenRefreshBeforeExpiry)) +} + +func (s *Server) loadMilestoneRuntimeConfig( + ctx context.Context, +) (milestoneRuntimeConfig, error) { + var config milestoneRuntimeConfig + var tokenExpiresAt sql.NullTime + + err := s.db.QueryRow( + ctx, + ` + SELECT + host, + COALESCE(pgp_sym_decrypt(access_token_encrypted, $1), ''), + COALESCE(token_type, ''), + skip_tls_verify, + token_expires_at + FROM milestone_settings + WHERE id = 1 + LIMIT 1 + `, + s.milestoneEncryptionKey(), + ).Scan( + &config.Host, + &config.AccessToken, + &config.TokenType, + &config.SkipTLSVerify, + &tokenExpiresAt, + ) + if err != nil { + return config, err + } + + if tokenExpiresAt.Valid { + config.TokenExpiresAt = &tokenExpiresAt.Time + } + + return config, nil +} + +func (s *Server) refreshMilestoneRuntimeToken(ctx context.Context) error { + credentials, err := s.getMilestoneCredentials(ctx) + if err != nil { + return err + } + + if credentials.Host == "" || credentials.Username == "" || credentials.Password == "" { + return errors.New("Milestone-Zugangsdaten sind unvollständig") + } + + tokenResponse, err := s.requestMilestoneToken(ctx, credentials) + if err != nil { + return err + } + + accessToken := strings.TrimSpace(tokenResponse.AccessToken) + if accessToken == "" { + return errors.New("Milestone hat keinen Access-Token zurückgegeben") + } + + tokenType := strings.TrimSpace(tokenResponse.TokenType) + if tokenType == "" { + tokenType = "Bearer" + } + + expiresIn := tokenResponse.ExpiresIn + if expiresIn <= 0 { + expiresIn = 3600 + } + + tokenExpiresAt := time.Now().Add(time.Duration(expiresIn) * time.Second) + + _, err = s.db.Exec( + ctx, + ` + UPDATE milestone_settings + SET + access_token_encrypted = pgp_sym_encrypt($1, $2), + token_type = $3, + token_expires_at = $4, + token_updated_at = now(), + updated_at = now() + WHERE id = 1 + `, + accessToken, + s.milestoneEncryptionKey(), + tokenType, + tokenExpiresAt, + ) + + return err +} + +func patchMilestoneHardware( + ctx context.Context, + config milestoneRuntimeConfig, + hardwareID string, + payload any, +) error { + body, err := json.Marshal(payload) + if err != nil { + return err + } + + requestURL := strings.TrimRight(config.Host, "/") + + "/API/rest/v1/hardware/" + + url.PathEscape(hardwareID) + + request, err := http.NewRequestWithContext( + ctx, + http.MethodPatch, + requestURL, + bytes.NewReader(body), + ) + if err != nil { + return err + } + + tokenType := strings.TrimSpace(config.TokenType) + if tokenType == "" { + tokenType = "Bearer" + } + + request.Header.Set("Authorization", tokenType+" "+config.AccessToken) + request.Header.Set("Accept", "application/json") + request.Header.Set("Content-Type", "application/json") + + response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request) + if err != nil { + return err + } + defer response.Body.Close() + + responseBody, _ := io.ReadAll(response.Body) + + if response.StatusCode < 200 || response.StatusCode >= 300 { + return newAppErrorf( + "milestone hardware patch failed", + LogFields{ + "statusCode": response.StatusCode, + "responseBody": string(responseBody), + "hardwareId": hardwareID, + }, + "milestone hardware patch failed: status=%d body=%s", + response.StatusCode, + string(responseBody), + ) + } + + return nil +} diff --git a/backend/milestone_devices.go b/backend/milestone_devices.go index ffa12ab..6d4c224 100644 --- a/backend/milestone_devices.go +++ b/backend/milestone_devices.go @@ -4,7 +4,6 @@ package main import ( "context" - "fmt" "strings" "unicode" @@ -28,6 +27,7 @@ func syncMilestoneHardwareDevices( hardwareDevice.SerialNumber = strings.TrimSpace(hardwareDevice.SerialNumber) hardwareDevice.MacAddress = strings.TrimSpace(hardwareDevice.MacAddress) hardwareDevice.IPAddress = strings.TrimSpace(hardwareDevice.IPAddress) + hardwareDevice.Port = strings.TrimSpace(hardwareDevice.Port) hardwareDevice.FirmwareVersion = strings.TrimSpace(hardwareDevice.FirmwareVersion) if hardwareDevice.HardwareID == "" { @@ -78,6 +78,7 @@ func createOrUpdateMilestoneHardwareDevice( serial_number, mac_address, ip_address, + milestone_port, location, loan_status, is_bao_device, @@ -96,10 +97,11 @@ func createOrUpdateMilestoneHardwareDevice( $4, $5, $6, + $7, '', 'Verfügbar', false, - $7, + '', $8, $9, $10, @@ -116,8 +118,7 @@ func createOrUpdateMilestoneHardwareDevice( serial_number = EXCLUDED.serial_number, mac_address = EXCLUDED.mac_address, ip_address = EXCLUDED.ip_address, - location = EXCLUDED.location, - comment = EXCLUDED.comment, + milestone_port = EXCLUDED.milestone_port, firmware_version = EXCLUDED.firmware_version, milestone_recording_server_id = EXCLUDED.milestone_recording_server_id, milestone_display_name = EXCLUDED.milestone_display_name, @@ -131,7 +132,7 @@ func createOrUpdateMilestoneHardwareDevice( hardwareDevice.SerialNumber, hardwareDevice.MacAddress, hardwareDevice.IPAddress, - buildMilestoneDeviceComment(hardwareDevice), + hardwareDevice.Port, hardwareDevice.FirmwareVersion, hardwareDevice.RecordingServerID, hardwareDevice.HardwareID, @@ -324,31 +325,3 @@ func buildGeneratedMilestoneInventoryNumber(hardwareID string) string { return "MS-" + value } - -func buildMilestoneDeviceComment(hardwareDevice milestoneHardwareDevice) string { - parts := []string{} - - if strings.TrimSpace(hardwareDevice.DisplayName) != "" { - parts = append(parts, strings.TrimSpace(hardwareDevice.DisplayName)) - } - - if strings.TrimSpace(hardwareDevice.DeviceName) != "" && - strings.TrimSpace(hardwareDevice.DeviceName) != strings.TrimSpace(hardwareDevice.DisplayName) { - parts = append(parts, "DeviceName: "+strings.TrimSpace(hardwareDevice.DeviceName)) - } - - if hardwareDevice.Enabled { - parts = append(parts, "Status: aktiviert") - } else { - parts = append(parts, "Status: deaktiviert") - } - - if len(parts) == 0 { - return "Automatisch aus Milestone synchronisiert." - } - - return fmt.Sprintf( - "Automatisch aus Milestone synchronisiert: %s", - strings.Join(parts, " · "), - ) -} diff --git a/backend/milestone_hardware.go b/backend/milestone_hardware.go deleted file mode 100644 index 55d9c96..0000000 --- a/backend/milestone_hardware.go +++ /dev/null @@ -1,338 +0,0 @@ -// backend/milestone_hardware.go - -package main - -import ( - "bytes" - "context" - "database/sql" - "encoding/json" - "errors" - "io" - "net/http" - "net/url" - "strings" - "time" - - "github.com/jackc/pgx/v5" -) - -type milestoneRuntimeConfig struct { - Host string - AccessToken string - TokenType string - SkipTLSVerify bool - TokenExpiresAt *time.Time -} - -type milestoneToggleDevice struct { - ID string - InventoryNumber string - MilestoneHardwareID string - MilestoneEnabled bool -} - -func (s *Server) handleToggleMilestoneDeviceStatus(w http.ResponseWriter, r *http.Request) { - user, ok := userFromContext(r.Context()) - if !ok { - writeError(w, http.StatusUnauthorized, "Unauthorized") - return - } - - deviceID := strings.TrimSpace(r.PathValue("id")) - if deviceID == "" { - writeError(w, http.StatusBadRequest, "Geräte-ID fehlt") - return - } - - device, err := s.getMilestoneToggleDevice(r.Context(), 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 - } - - if device.MilestoneHardwareID == "" { - writeError(w, http.StatusBadRequest, "Dieses Gerät ist kein Milestone-Gerät") - return - } - - config, err := s.getMilestoneRuntimeConfig(r.Context()) - if errors.Is(err, pgx.ErrNoRows) { - writeError(w, http.StatusBadRequest, "Milestone-Einstellungen sind noch nicht hinterlegt") - return - } - - if err != nil { - writeError(w, http.StatusInternalServerError, "Milestone-Einstellungen konnten nicht geladen werden") - return - } - - if config.Host == "" || config.AccessToken == "" { - writeError(w, http.StatusBadRequest, "Milestone Host oder Token fehlt") - return - } - - if config.TokenExpiresAt != nil && time.Now().After(config.TokenExpiresAt.Add(-30*time.Second)) { - writeError(w, http.StatusBadRequest, "Milestone-Token ist abgelaufen. Bitte Token neu abrufen.") - return - } - - nextEnabled := !device.MilestoneEnabled - - if err := patchMilestoneHardware( - r.Context(), - config, - device.MilestoneHardwareID, - map[string]bool{ - "enabled": nextEnabled, - }, - ); err != nil { - s.logAndWriteUserError( - w, - r, - user, - http.StatusBadGateway, - "milestone_hardware", - "patch_hardware", - "Milestone-Status konnte nicht geändert werden", - err, - LogFields{ - "deviceId": device.ID, - "inventoryNumber": device.InventoryNumber, - "milestoneHardwareId": device.MilestoneHardwareID, - "currentEnabled": device.MilestoneEnabled, - "nextEnabled": nextEnabled, - "payload": LogFields{ - "enabled": nextEnabled, - }, - }, - ) - return - } - - _, err = s.db.Exec( - r.Context(), - ` - UPDATE devices - SET - milestone_enabled = $2, - milestone_synced_at = now(), - updated_at = now() - WHERE id = $1 - `, - device.ID, - nextEnabled, - ) - if err != nil { - s.logAndWriteUserError( - w, - r, - user, - http.StatusInternalServerError, - "milestone_hardware", - "update_local_device", - "Milestone-Status konnte lokal nicht aktualisiert werden", - err, - LogFields{ - "deviceId": device.ID, - "inventoryNumber": device.InventoryNumber, - "milestoneHardwareId": device.MilestoneHardwareID, - "nextEnabled": nextEnabled, - }, - ) - return - } - - oldValue := "Aktiviert" - newValue := "Deaktiviert" - - if nextEnabled { - oldValue = "Deaktiviert" - newValue = "Aktiviert" - } - - changes, _ := json.Marshal([]map[string]string{ - { - "field": "milestoneEnabled", - "label": "Milestone-Status", - "oldValue": oldValue, - "newValue": newValue, - }, - }) - - _, _ = s.db.Exec( - r.Context(), - ` - INSERT INTO device_history ( - device_id, - user_id, - action, - changes - ) - VALUES ($1, $2, 'updated', $3) - `, - device.ID, - user.ID, - changes, - ) - - if err := s.publishDeviceChangeEvent( - r.Context(), - user.ID, - device.ID, - "device.milestone.status", - "Milestone-Status geändert", - formatText( - "Gerät %s wurde in Milestone %s.", - device.InventoryNumber, - strings.ToLower(newValue), - ), - LogFields{ - "inventoryNumber": device.InventoryNumber, - "milestoneHardwareId": device.MilestoneHardwareID, - "milestoneEnabled": nextEnabled, - }, - ); err != nil { - logError("Device-SSE-Event konnte nicht gesendet werden", err, LogFields{ - "deviceId": device.ID, - "type": "device.milestone.status", - }) - } - - writeJSON(w, http.StatusOK, map[string]any{ - "ok": true, - "id": device.ID, - "milestoneEnabled": nextEnabled, - }) -} - -func (s *Server) getMilestoneToggleDevice( - ctx context.Context, - deviceID string, -) (milestoneToggleDevice, error) { - var device milestoneToggleDevice - - err := s.db.QueryRow( - ctx, - ` - SELECT - id::TEXT, - inventory_number, - COALESCE(milestone_hardware_id, ''), - milestone_enabled - FROM devices - WHERE id = $1 - LIMIT 1 - `, - deviceID, - ).Scan( - &device.ID, - &device.InventoryNumber, - &device.MilestoneHardwareID, - &device.MilestoneEnabled, - ) - - return device, err -} - -func (s *Server) getMilestoneRuntimeConfig( - ctx context.Context, -) (milestoneRuntimeConfig, error) { - var config milestoneRuntimeConfig - var tokenExpiresAt sql.NullTime - - err := s.db.QueryRow( - ctx, - ` - SELECT - host, - COALESCE(pgp_sym_decrypt(access_token_encrypted, $1), ''), - COALESCE(token_type, ''), - skip_tls_verify, - token_expires_at - FROM milestone_settings - WHERE id = 1 - LIMIT 1 - `, - s.milestoneEncryptionKey(), - ).Scan( - &config.Host, - &config.AccessToken, - &config.TokenType, - &config.SkipTLSVerify, - &tokenExpiresAt, - ) - if err != nil { - return config, err - } - - if tokenExpiresAt.Valid { - config.TokenExpiresAt = &tokenExpiresAt.Time - } - - return config, nil -} - -func patchMilestoneHardware( - ctx context.Context, - config milestoneRuntimeConfig, - hardwareID string, - payload any, -) error { - body, err := json.Marshal(payload) - if err != nil { - return err - } - - requestURL := strings.TrimRight(config.Host, "/") + - "/API/rest/v1/hardware/" + - url.PathEscape(hardwareID) - - request, err := http.NewRequestWithContext( - ctx, - http.MethodPatch, - requestURL, - bytes.NewReader(body), - ) - if err != nil { - return err - } - - tokenType := strings.TrimSpace(config.TokenType) - if tokenType == "" { - tokenType = "Bearer" - } - - request.Header.Set("Authorization", tokenType+" "+config.AccessToken) - request.Header.Set("Accept", "application/json") - request.Header.Set("Content-Type", "application/json") - - response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request) - if err != nil { - return err - } - defer response.Body.Close() - - responseBody, _ := io.ReadAll(response.Body) - - if response.StatusCode < 200 || response.StatusCode >= 300 { - return newAppErrorf( - "milestone hardware patch failed", - LogFields{ - "statusCode": response.StatusCode, - "responseBody": string(responseBody), - "hardwareId": hardwareID, - }, - "milestone hardware patch failed: status=%d body=%s", - response.StatusCode, - string(responseBody), - ) - } - - return nil -} diff --git a/backend/notification_preferences.go b/backend/notification_preferences.go index 486ad5e..e0fcfd1 100644 --- a/backend/notification_preferences.go +++ b/backend/notification_preferences.go @@ -1,29 +1,33 @@ -// backend\notification_preferences.go +// backend/notification_preferences.go package main import ( + "context" "errors" "net/http" + "strings" "github.com/jackc/pgx/v5" ) type notificationPreferencesResponse struct { - InAppEnabled bool `json:"inAppEnabled"` - SoundEnabled bool `json:"soundEnabled"` - EmailEnabled bool `json:"emailEnabled"` - OperationUpdates bool `json:"operationUpdates"` - OperationJournals bool `json:"operationJournals"` - DeviceUpdates bool `json:"deviceUpdates"` - DeviceJournals bool `json:"deviceJournals"` - SystemMessages bool `json:"systemMessages"` + InAppEnabled bool `json:"inAppEnabled"` + SoundEnabled bool `json:"soundEnabled"` + SoundName string `json:"soundName"` + EmailEnabled bool `json:"emailEnabled"` + OperationUpdates bool `json:"operationUpdates"` + OperationJournals bool `json:"operationJournals"` + DeviceUpdates bool `json:"deviceUpdates"` + DeviceJournals bool `json:"deviceJournals"` + SystemMessages bool `json:"systemMessages"` } func defaultNotificationPreferences() notificationPreferencesResponse { return notificationPreferencesResponse{ InAppEnabled: true, SoundEnabled: false, + SoundName: "default", EmailEnabled: false, OperationUpdates: true, OperationJournals: true, @@ -33,6 +37,17 @@ func defaultNotificationPreferences() notificationPreferencesResponse { } } +func normalizeNotificationSoundName(value string) string { + value = strings.TrimSpace(strings.ToLower(value)) + + switch value { + case "default", "chime", "soft", "alert", "success": + return value + default: + return "default" + } +} + func (s *Server) handleGetNotificationPreferences(w http.ResponseWriter, r *http.Request) { user, ok := userFromContext(r.Context()) if !ok { @@ -40,7 +55,7 @@ func (s *Server) handleGetNotificationPreferences(w http.ResponseWriter, r *http return } - settings, err := s.getNotificationPreferences(r, user.ID) + settings, err := s.getNotificationPreferences(r.Context(), user.ID) if errors.Is(err, pgx.ErrNoRows) { writeJSON(w, http.StatusOK, map[string]any{ "settings": defaultNotificationPreferences(), @@ -72,6 +87,8 @@ func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *h return } + input.SoundName = normalizeNotificationSoundName(input.SoundName) + _, err := s.db.Exec( r.Context(), ` @@ -79,6 +96,7 @@ func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *h user_id, in_app_enabled, sound_enabled, + sound_name, email_enabled, operation_updates, operation_journals, @@ -86,11 +104,12 @@ func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *h device_journals, system_messages ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT (user_id) DO UPDATE SET in_app_enabled = EXCLUDED.in_app_enabled, sound_enabled = EXCLUDED.sound_enabled, + sound_name = EXCLUDED.sound_name, email_enabled = EXCLUDED.email_enabled, operation_updates = EXCLUDED.operation_updates, operation_journals = EXCLUDED.operation_journals, @@ -102,6 +121,7 @@ func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *h user.ID, input.InAppEnabled, input.SoundEnabled, + input.SoundName, input.EmailEnabled, input.OperationUpdates, input.OperationJournals, @@ -114,7 +134,7 @@ func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *h return } - settings, err := s.getNotificationPreferences(r, user.ID) + settings, err := s.getNotificationPreferences(r.Context(), user.ID) if err != nil { writeError(w, http.StatusInternalServerError, "Benachrichtigungseinstellungen wurden gespeichert, konnten aber nicht neu geladen werden") return @@ -125,15 +145,16 @@ func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *h }) } -func (s *Server) getNotificationPreferences(r *http.Request, userID string) (notificationPreferencesResponse, error) { +func (s *Server) getNotificationPreferences(ctx context.Context, userID string) (notificationPreferencesResponse, error) { settings := defaultNotificationPreferences() err := s.db.QueryRow( - r.Context(), + ctx, ` SELECT in_app_enabled, sound_enabled, + COALESCE(sound_name, 'default'), email_enabled, operation_updates, operation_journals, @@ -148,6 +169,7 @@ func (s *Server) getNotificationPreferences(r *http.Request, userID string) (not ).Scan( &settings.InAppEnabled, &settings.SoundEnabled, + &settings.SoundName, &settings.EmailEnabled, &settings.OperationUpdates, &settings.OperationJournals, @@ -156,5 +178,16 @@ func (s *Server) getNotificationPreferences(r *http.Request, userID string) (not &settings.SystemMessages, ) + settings.SoundName = normalizeNotificationSoundName(settings.SoundName) + + return settings, err +} + +func (s *Server) getNotificationPreferencesOrDefault(ctx context.Context, userID string) (notificationPreferencesResponse, error) { + settings, err := s.getNotificationPreferences(ctx, userID) + if errors.Is(err, pgx.ErrNoRows) { + return defaultNotificationPreferences(), nil + } + return settings, err } diff --git a/backend/notifications.go b/backend/notifications.go index 2b7040b..7d52c01 100644 --- a/backend/notifications.go +++ b/backend/notifications.go @@ -1,4 +1,4 @@ -// backend\notifications.go +// backend/notifications.go package main @@ -22,7 +22,8 @@ type Notification struct { EntityType string `json:"entityType"` EntityID string `json:"entityId"` Data json.RawMessage `json:"data"` - Visible bool `json:"visible"` + Silent bool `json:"silent"` + SoundName string `json:"soundName,omitempty"` ReadAt *time.Time `json:"readAt"` CreatedAt time.Time `json:"createdAt"` } @@ -77,6 +78,151 @@ func (b *notificationBroker) publish(notification Notification) { } } +func (b *notificationBroker) publishToAll(notification Notification) { + b.mu.RLock() + defer b.mu.RUnlock() + + for client := range b.clients { + select { + case client.ch <- notification: + default: + } + } +} + +func shouldDeliverNotification( + preferences notificationPreferencesResponse, + notificationType string, + entityType string, +) bool { + if !preferences.InAppEnabled { + return false + } + + switch { + case notificationType == "operation.journal": + return preferences.OperationJournals + + case strings.HasPrefix(notificationType, "operation.") || entityType == "operation": + return preferences.OperationUpdates + + case notificationType == "device.journal": + return preferences.DeviceJournals + + case strings.HasPrefix(notificationType, "device.") || entityType == "device": + return preferences.DeviceUpdates + + case strings.HasPrefix(notificationType, "system.") || entityType == "system": + return preferences.SystemMessages + + default: + return true + } +} + +func notificationSoundName(preferences notificationPreferencesResponse) string { + if !preferences.SoundEnabled { + return "" + } + + return normalizeNotificationSoundName(preferences.SoundName) +} + +func (s *Server) createAndPublishNotification( + ctx context.Context, + recipientID string, + notificationType string, + title string, + message string, + entityType string, + entityID string, + dataJSON []byte, + silent bool, +) error { + preferences, err := s.getNotificationPreferencesOrDefault(ctx, recipientID) + if err != nil { + return err + } + + // Sichtbare Benachrichtigungen respektieren die User-Preferences. + // Stille Events werden immer erzeugt, weil sie das UI synchronisieren. + if !silent && !shouldDeliverNotification(preferences, notificationType, entityType) { + return nil + } + + var notification Notification + var rawData []byte + + err = s.db.QueryRow( + ctx, + ` + INSERT INTO notifications ( + user_id, + type, + title, + message, + entity_type, + entity_id, + data, + silent + ) + VALUES ( + $1, + $2, + $3, + $4, + $5, + NULLIF($6, '')::UUID, + $7::JSONB, + $8 + ) + RETURNING + id::TEXT, + user_id::TEXT, + type, + title, + message, + entity_type, + COALESCE(entity_id::TEXT, ''), + data, + silent, + created_at + `, + recipientID, + notificationType, + title, + message, + entityType, + entityID, + string(dataJSON), + silent, + ).Scan( + ¬ification.ID, + ¬ification.UserID, + ¬ification.Type, + ¬ification.Title, + ¬ification.Message, + ¬ification.EntityType, + ¬ification.EntityID, + &rawData, + ¬ification.Silent, + ¬ification.CreatedAt, + ) + if err != nil { + return err + } + + notification.Data = json.RawMessage(rawData) + + if !notification.Silent { + notification.SoundName = notificationSoundName(preferences) + } + + notificationsBroker.publish(notification) + + return nil +} + func (s *Server) handleNotificationEvents(w http.ResponseWriter, r *http.Request) { user, ok := userFromContext(r.Context()) if !ok { @@ -115,12 +261,14 @@ func (s *Server) handleNotificationEvents(w http.ResponseWriter, r *http.Request eventName := "notification" - if !notification.Visible { + if notification.Silent { switch notification.EntityType { case "operation": eventName = "operation-event" case "device": eventName = "device-event" + case "user": + eventName = "user-event" default: eventName = "notification-event" } @@ -152,12 +300,12 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request) entity_type, COALESCE(entity_id::TEXT, ''), data, - visible, + silent, read_at, created_at FROM notifications WHERE user_id = $1 - AND visible = true + AND silent = false ORDER BY created_at DESC LIMIT 50 `, @@ -186,7 +334,7 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request) ¬ification.EntityType, ¬ification.EntityID, &data, - ¬ification.Visible, + ¬ification.Silent, &readAt, ¬ification.CreatedAt, ); err != nil { @@ -307,76 +455,25 @@ func (s *Server) createOperationNotification( return err } - var notification Notification - var rawData []byte - - err := s.db.QueryRow( + if err := s.createAndPublishNotification( ctx, - ` - INSERT INTO notifications ( - user_id, - type, - title, - message, - entity_type, - entity_id, - data, - visible - ) - VALUES ( - $1, - $2, - $3, - $4, - 'operation', - $5, - $6::JSONB, - true - ) - RETURNING - id::TEXT, - user_id::TEXT, - type, - title, - message, - entity_type, - COALESCE(entity_id::TEXT, ''), - data, - visible, - created_at - `, recipientID, notificationType, title, message, + "operation", operation.ID, - string(dataJSON), - ).Scan( - ¬ification.ID, - ¬ification.UserID, - ¬ification.Type, - ¬ification.Title, - ¬ification.Message, - ¬ification.EntityType, - ¬ification.EntityID, - &rawData, - ¬ification.Visible, - ¬ification.CreatedAt, - ) - - if err != nil { + dataJSON, + false, + ); err != nil { return err } - - notification.Data = json.RawMessage(rawData) - - notificationsBroker.publish(notification) } return rows.Err() } -func (s *Server) createOperationUpdateEvent( +func (s *Server) createOperationCreatedEvent( ctx context.Context, actor User, operation Operation, @@ -390,7 +487,7 @@ func (s *Server) createOperationUpdateEvent( rows, err := s.db.Query( ctx, ` - SELECT id + SELECT id::TEXT FROM users WHERE id <> $1 AND ( @@ -412,66 +509,73 @@ func (s *Server) createOperationUpdateEvent( return err } - var notification Notification - var rawData []byte - - err := s.db.QueryRow( + if err := s.createAndPublishNotification( ctx, - ` - INSERT INTO notifications ( - user_id, - type, - title, - message, - entity_type, - entity_id, - data, - visible - ) - VALUES ( - $1, - 'operation.updated', - 'Einsatz geändert', - '', - 'operation', - $2, - $3::JSONB, - false - ) - RETURNING - id::TEXT, - user_id::TEXT, - type, - title, - message, - entity_type, - COALESCE(entity_id::TEXT, ''), - data, - visible, - created_at - `, recipientID, + "operation.created", + "Einsatz erstellt", + "", + "operation", operation.ID, - string(dataJSON), - ).Scan( - ¬ification.ID, - ¬ification.UserID, - ¬ification.Type, - ¬ification.Title, - ¬ification.Message, - ¬ification.EntityType, - ¬ification.EntityID, - &rawData, - ¬ification.Visible, - ¬ification.CreatedAt, - ) + dataJSON, + true, + ); err != nil { + return err + } + } - if err != nil { + return rows.Err() +} + +func (s *Server) createOperationUpdateEvent( + ctx context.Context, + actor User, + operation Operation, + data map[string]any, +) error { + dataJSON, err := json.Marshal(data) + if err != nil { + return err + } + + rows, err := s.db.Query( + ctx, + ` + SELECT id::TEXT + FROM users + WHERE id <> $1 + AND ( + 'admin' = ANY(rights) + OR 'operations:read' = ANY(rights) + ) + `, + actor.ID, + ) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var recipientID string + + if err := rows.Scan(&recipientID); err != nil { return err } - notification.Data = json.RawMessage(rawData) - notificationsBroker.publish(notification) + if err := s.createAndPublishNotification( + ctx, + recipientID, + "operation.updated", + "Einsatz geändert", + "", + "operation", + operation.ID, + dataJSON, + true, + ); err != nil { + return err + } } return rows.Err() @@ -491,7 +595,7 @@ func (s *Server) createOperationJournalEvent( rows, err := s.db.Query( ctx, ` - SELECT id + SELECT id::TEXT FROM users WHERE id <> $1 `, @@ -504,70 +608,24 @@ func (s *Server) createOperationJournalEvent( for rows.Next() { var recipientID string + if err := rows.Scan(&recipientID); err != nil { return err } - var notification Notification - var rawData []byte - - err := s.db.QueryRow( + if err := s.createAndPublishNotification( ctx, - ` - INSERT INTO notifications ( - user_id, - type, - title, - message, - entity_type, - entity_id, - data, - visible - ) - VALUES ( - $1, - 'operation.journal', - 'Journal aktualisiert', - 'Ein Journal-Eintrag wurde aktualisiert.', - 'operation', - $2, - $3::JSONB, - false - ) - RETURNING - id, - user_id::TEXT, - type, - title, - message, - entity_type, - COALESCE(entity_id::TEXT, ''), - data, - visible, - created_at - `, recipientID, + "operation.journal", + "Journal aktualisiert", + "Ein Journal-Eintrag wurde aktualisiert.", + "operation", operation.ID, - string(dataJSON), - ).Scan( - ¬ification.ID, - ¬ification.UserID, - ¬ification.Type, - ¬ification.Title, - ¬ification.Message, - ¬ification.EntityType, - ¬ification.EntityID, - &rawData, - ¬ification.Visible, - ¬ification.CreatedAt, - ) - - if err != nil { + dataJSON, + true, + ); err != nil { return err } - - notification.Data = json.RawMessage(rawData) - notificationsBroker.publish(notification) } return rows.Err() @@ -587,7 +645,7 @@ func (s *Server) createDeviceJournalEvent( rows, err := s.db.Query( ctx, ` - SELECT id + SELECT id::TEXT FROM users WHERE id <> $1 AND ( @@ -604,70 +662,24 @@ func (s *Server) createDeviceJournalEvent( for rows.Next() { var recipientID string + if err := rows.Scan(&recipientID); err != nil { return err } - var notification Notification - var rawData []byte - - err := s.db.QueryRow( + if err := s.createAndPublishNotification( ctx, - ` - INSERT INTO notifications ( - user_id, - type, - title, - message, - entity_type, - entity_id, - data, - visible - ) - VALUES ( - $1, - 'device.journal', - 'Geräte-Journal aktualisiert', - 'Ein Geräte-Journal-Eintrag wurde hinzugefügt.', - 'device', - $2, - $3::JSONB, - false - ) - RETURNING - id, - user_id::TEXT, - type, - title, - message, - entity_type, - COALESCE(entity_id::TEXT, ''), - data, - visible, - created_at - `, recipientID, + "device.journal", + "Geräte-Journal aktualisiert", + "Ein Geräte-Journal-Eintrag wurde hinzugefügt.", + "device", device.ID, - string(dataJSON), - ).Scan( - ¬ification.ID, - ¬ification.UserID, - ¬ification.Type, - ¬ification.Title, - ¬ification.Message, - ¬ification.EntityType, - ¬ification.EntityID, - &rawData, - ¬ification.Visible, - ¬ification.CreatedAt, - ) - - if err != nil { + dataJSON, + true, + ); err != nil { return err } - - notification.Data = json.RawMessage(rawData) - notificationsBroker.publish(notification) } return rows.Err() diff --git a/backend/operation_assignees.go b/backend/operation_assignees.go new file mode 100644 index 0000000..bd1a264 --- /dev/null +++ b/backend/operation_assignees.go @@ -0,0 +1,101 @@ +// backend\operation_assignees.go + +package main + +import ( + "context" + "net/http" + "strings" +) + +type operationAssigneeUser 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"` + Group string `json:"group"` +} + +func (s *Server) handleListOperationAssignees(w http.ResponseWriter, r *http.Request) { + unit := strings.TrimSpace(r.URL.Query().Get("unit")) + if unit == "" { + unit = "TEG" + } + + leaders, err := s.listOperationAssigneeUsers(r.Context(), unit, "operation-leader") + if err != nil { + writeError(w, http.StatusInternalServerError, "Einsatzleiter konnten nicht geladen werden") + return + } + + officers, err := s.listOperationAssigneeUsers(r.Context(), unit, "operation-officer") + if err != nil { + writeError(w, http.StatusInternalServerError, "Einsatzbeamte konnten nicht geladen werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "leaders": leaders, + "officers": officers, + }) +} + +func (s *Server) listOperationAssigneeUsers( + ctx context.Context, + unit string, + group string, +) ([]operationAssigneeUser, error) { + rows, err := s.db.Query( + ctx, + ` + SELECT + id::TEXT, + COALESCE(username, ''), + COALESCE(display_name, ''), + email, + COALESCE(avatar, ''), + COALESCE(unit, ''), + COALESCE(user_group, '') + FROM users + WHERE upper(COALESCE(unit, '')) = upper($1) + AND COALESCE(user_group, '') = $2 + ORDER BY + lower(COALESCE(NULLIF(display_name, ''), NULLIF(username, ''), email)) ASC, + lower(email) ASC + `, + unit, + group, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + users := []operationAssigneeUser{} + + for rows.Next() { + var user operationAssigneeUser + + if err := rows.Scan( + &user.ID, + &user.Username, + &user.DisplayName, + &user.Email, + &user.Avatar, + &user.Unit, + &user.Group, + ); err != nil { + return nil, err + } + + users = append(users, user) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return users, nil +} diff --git a/backend/operations.go b/backend/operations.go index 4e21445..adb4e44 100644 --- a/backend/operations.go +++ b/backend/operations.go @@ -333,6 +333,19 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) { // nicht abbrechen, Einsatz wurde bereits erstellt } + if err := s.createOperationCreatedEvent( + r.Context(), + user, + operation, + map[string]any{ + "operationNumber": operation.OperationNumber, + "operationName": operation.OperationName, + "action": "created", + }, + ); err != nil { + // nicht abbrechen, Einsatz wurde bereits erstellt + } + writeJSON(w, http.StatusCreated, map[string]any{ "operation": operation, }) @@ -1051,7 +1064,7 @@ func (s *Server) handleListUnreadOperationJournals(w http.ResponseWriter, r *htt SELECT entity_id::TEXT FROM notifications WHERE user_id = $1 - AND visible = false + AND silent = true AND type = 'operation.journal' AND entity_type = 'operation' AND entity_id IS NOT NULL @@ -1103,7 +1116,7 @@ func (s *Server) handleMarkOperationJournalRead(w http.ResponseWriter, r *http.R UPDATE notifications SET read_at = COALESCE(read_at, now()) WHERE user_id = $1 - AND visible = false + AND silent = true AND type = 'operation.journal' AND entity_type = 'operation' AND entity_id = $2 diff --git a/backend/routes.go b/backend/routes.go index c67a8ae..a6d418c 100644 --- a/backend/routes.go +++ b/backend/routes.go @@ -1,4 +1,4 @@ -// backend/routes.go +// backend\routes.go package main @@ -29,9 +29,12 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("GET /devices", s.requireAuth(s.handleListDevices)) mux.HandleFunc("POST /devices", s.requireAuth(s.handleCreateDevice)) mux.HandleFunc("PUT /devices/{id}", s.requireAuth(s.handleUpdateDevice)) - mux.HandleFunc("POST /devices/{id}/milestone-toggle", s.requireAuth(s.handleToggleMilestoneDeviceStatus)) + mux.HandleFunc("GET /devices/{id}/history", s.requireAuth(s.handleListDeviceHistory)) mux.HandleFunc("POST /devices/{id}/history", s.requireAuth(s.handleCreateDeviceJournalEntry)) + mux.HandleFunc("PUT /devices/{id}/history/{historyId}", s.requireAuth(s.handleUpdateDeviceJournalEntry)) + + mux.HandleFunc("PATCH /devices/{id}/milestone", s.requireAuth(s.handleUpdateMilestoneHardware)) mux.HandleFunc("GET /devices/firmware-check", s.requireAuth(s.handleGetCameraFirmwareCheckState)) mux.HandleFunc("POST /devices/firmware-check", s.requireAuth(s.handleCheckCameraFirmware)) @@ -42,16 +45,21 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("GET /operations", s.requireAuth(s.requireRight("operations:read", s.handleListOperations))) mux.HandleFunc("POST /operations", s.requireAuth(s.requireRight("operations:write", s.handleCreateOperation))) + mux.HandleFunc("PUT /operations/{id}", s.requireAuth(s.requireRight("operations:write", s.handleUpdateOperation))) mux.HandleFunc("GET /operations/{id}/history", s.requireAuth(s.handleListOperationHistory)) mux.HandleFunc("POST /operations/{id}/history", s.requireAuth(s.handleCreateOperationJournalEntry)) mux.HandleFunc("PUT /operations/{id}/history/{historyId}", s.requireAuth(s.handleUpdateOperationJournalEntry)) - mux.HandleFunc("GET /operations/journal-unread", s.requireAuth(s.handleListUnreadOperationJournals)) mux.HandleFunc("POST /operations/{id}/journal/read", s.requireAuth(s.handleMarkOperationJournalRead)) + mux.HandleFunc("GET /operations/journal-unread", s.requireAuth(s.handleListUnreadOperationJournals)) + + mux.HandleFunc("GET /operations/assignees", s.requireAuth(s.requireRight("operations:read", s.handleListOperationAssignees))) + mux.HandleFunc("GET /search", s.requireAuth(s.handleGlobalSearch)) mux.HandleFunc("GET /address-search", s.requireAuth(s.handleAddressSearch)) + mux.HandleFunc("GET /address-reverse", s.requireAuth(s.handleAddressReverse)) mux.HandleFunc("GET /notifications", s.requireAuth(s.handleListNotifications)) mux.HandleFunc("POST /notifications/{id}/read", s.requireAuth(s.handleMarkNotificationRead)) @@ -75,6 +83,9 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("POST /admin/users", s.requireAuth(s.requireRight("users:write", s.handleAdminCreateUser))) mux.HandleFunc("PUT /admin/users/{id}", s.requireAuth(s.requireRight("users:write", s.handleAdminUpdateUser))) + mux.HandleFunc("GET /admin/user-units", s.requireAuth(s.requireRight("users:read", s.handleListUserUnits))) + mux.HandleFunc("POST /admin/user-units", s.requireAuth(s.requireRight("users:write", s.handleCreateUserUnit))) + mux.HandleFunc("GET /admin/teams", s.requireAuth(s.requireRight("teams:read", s.handleAdminListTeams))) mux.HandleFunc("POST /admin/teams", s.requireAuth(s.requireRight("teams:write", s.handleAdminCreateTeam))) mux.HandleFunc("PUT /admin/teams/{id}", s.requireAuth(s.requireRight("teams:write", s.handleAdminUpdateTeam))) diff --git a/backend/setup/main.go b/backend/setup/main.go index d12bfc2..b1c353d 100644 --- a/backend/setup/main.go +++ b/backend/setup/main.go @@ -264,6 +264,18 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { ) `, + ` + CREATE TABLE IF NOT EXISTS user_units ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + name TEXT NOT NULL, + normalized_name TEXT NOT NULL UNIQUE, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `, + ` CREATE TABLE IF NOT EXISTS user_teams ( user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -326,6 +338,7 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { milestone_hardware_id TEXT NOT NULL DEFAULT '', milestone_display_name TEXT NOT NULL DEFAULT '', firmware_version TEXT NOT NULL DEFAULT '', + milestone_port TEXT NOT NULL DEFAULT '', milestone_enabled BOOLEAN NOT NULL DEFAULT true, milestone_synced_at TIMESTAMPTZ, @@ -380,7 +393,8 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { action TEXT NOT NULL, changes JSONB NOT NULL DEFAULT '[]'::JSONB, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) `, @@ -434,7 +448,7 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { type TEXT NOT NULL DEFAULT 'info', title TEXT NOT NULL, message TEXT NOT NULL DEFAULT '', - visible BOOLEAN NOT NULL DEFAULT true, + silent BOOLEAN NOT NULL DEFAULT false, entity_type TEXT NOT NULL DEFAULT '', entity_id UUID, @@ -476,6 +490,7 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { in_app_enabled BOOLEAN NOT NULL DEFAULT true, sound_enabled BOOLEAN NOT NULL DEFAULT false, + sound_name TEXT NOT NULL DEFAULT 'default', email_enabled BOOLEAN NOT NULL DEFAULT false, operation_updates BOOLEAN NOT NULL DEFAULT true, @@ -564,11 +579,15 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { `CREATE INDEX IF NOT EXISTS idx_user_teams_user_id ON user_teams(user_id)`, `CREATE INDEX IF NOT EXISTS idx_user_teams_team_id ON user_teams(team_id)`, + `CREATE INDEX IF NOT EXISTS idx_user_units_name ON user_units(name)`, + `CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_user_id ON dashboard_widgets(user_id)`, `CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_position ON dashboard_widgets(user_id, y, x)`, `CREATE INDEX IF NOT EXISTS idx_dashboard_settings_user_id ON dashboard_settings(user_id)`, + `ALTER TABLE devices ADD COLUMN IF NOT EXISTS milestone_port TEXT NOT NULL DEFAULT ''`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_inventory_number_unique ON devices(lower(inventory_number))`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_serial_number_unique ON devices(lower(serial_number)) WHERE serial_number <> ''`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_mac_address_unique ON devices(lower(mac_address)) WHERE mac_address <> ''`, diff --git a/backend/user_events.go b/backend/user_events.go new file mode 100644 index 0000000..33817b8 --- /dev/null +++ b/backend/user_events.go @@ -0,0 +1,33 @@ +// backend\user_events.go + +package main + +import ( + "encoding/json" + "fmt" + "time" +) + +func (s *Server) publishUserProfileEvent(eventType string, user User) { + data, err := json.Marshal(map[string]any{ + "user": user, + }) + if err != nil { + return + } + + now := time.Now() + + notificationsBroker.publishToAll(Notification{ + ID: fmt.Sprintf("%s-%s-%d", eventType, user.ID, now.UnixNano()), + UserID: user.ID, + Type: eventType, + Title: "", + Message: "", + EntityType: "user", + EntityID: user.ID, + Data: json.RawMessage(data), + Silent: true, + CreatedAt: now, + }) +} diff --git a/backend/user_settings.go b/backend/user_settings.go index eee4b28..a757c0d 100644 --- a/backend/user_settings.go +++ b/backend/user_settings.go @@ -114,6 +114,8 @@ func (s *Server) handleUpdateSettingsAccount(w http.ResponseWriter, r *http.Requ return } + s.publishUserProfileEvent("user.updated", user) + writeSettingsJSON(w, http.StatusOK, map[string]any{ "message": "Profil gespeichert", "user": user, diff --git a/backend/user_unit_lookups.go b/backend/user_unit_lookups.go new file mode 100644 index 0000000..f5a8685 --- /dev/null +++ b/backend/user_unit_lookups.go @@ -0,0 +1,21 @@ +// backend\user_unit_lookups.go + +package main + +import "net/http" + +func (s *Server) handleListUserUnits(w http.ResponseWriter, r *http.Request) { + units, err := listDeviceLookupOptions(r.Context(), s.db, "user_units") + if err != nil { + writeError(w, http.StatusInternalServerError, "Einheiten konnten nicht geladen werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "units": units, + }) +} + +func (s *Server) handleCreateUserUnit(w http.ResponseWriter, r *http.Request) { + s.handleCreateDeviceLookup(w, r, "user_units", "Einheit") +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b390f98..3d53f0b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -22,6 +22,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@types/geojson": "^7946.0.16", "@types/leaflet": "^1.9.21", "@types/node": "^24.12.3", "@types/react": "^19.2.14", diff --git a/frontend/package.json b/frontend/package.json index dfe403f..7aae7d1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,6 +24,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@types/geojson": "^7946.0.16", "@types/leaflet": "^1.9.21", "@types/node": "^24.12.3", "@types/react": "^19.2.14", diff --git a/frontend/public/sounds/notifications/alert.mp3 b/frontend/public/sounds/notifications/alert.mp3 new file mode 100644 index 0000000..f4089f2 Binary files /dev/null and b/frontend/public/sounds/notifications/alert.mp3 differ diff --git a/frontend/public/sounds/notifications/chime.mp3 b/frontend/public/sounds/notifications/chime.mp3 new file mode 100644 index 0000000..c71bf1d Binary files /dev/null and b/frontend/public/sounds/notifications/chime.mp3 differ diff --git a/frontend/public/sounds/notifications/default.mp3 b/frontend/public/sounds/notifications/default.mp3 new file mode 100644 index 0000000..d37f519 Binary files /dev/null and b/frontend/public/sounds/notifications/default.mp3 differ diff --git a/frontend/public/sounds/notifications/soft.mp3 b/frontend/public/sounds/notifications/soft.mp3 new file mode 100644 index 0000000..29c89b8 Binary files /dev/null and b/frontend/public/sounds/notifications/soft.mp3 differ diff --git a/frontend/public/sounds/notifications/success.mp3 b/frontend/public/sounds/notifications/success.mp3 new file mode 100644 index 0000000..3e8b935 Binary files /dev/null and b/frontend/public/sounds/notifications/success.mp3 differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 84e497b..29aa7e3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,8 @@ // frontend/src/App.tsx import { useEffect, useState } from 'react' -import { Navigate, Route, Routes } from 'react-router' +import { Navigate, Route, Routes, useNavigate } from 'react-router' +import { NotificationProvider } from './components/Notifications' import LoginPage from './pages/LoginPage' import AppLayout from './components/AppLayout' import DashboardPage from './pages/DashboardPage' @@ -29,6 +30,52 @@ function parseNotificationEvent(event: MessageEvent) { } } +function getNotificationData(notification: Notification) { + const data = (notification as { data?: unknown }).data + + if (!data) { + return {} + } + + if (typeof data === 'string') { + try { + return JSON.parse(data) as Record + } catch { + return {} + } + } + + if (typeof data === 'object') { + return data as Record + } + + return {} +} + +function getUserFromNotification(notification: Notification) { + const data = getNotificationData(notification) + const user = data.user + + if (!user || typeof user !== 'object') { + return null + } + + return user as User +} + +function playNotificationSound(soundName?: string) { + if (!soundName) { + return + } + + const audio = new Audio(`/sounds/notifications/${soundName}.mp3`) + audio.volume = 0.6 + + void audio.play().catch(() => { + // Browser blockiert Audio ggf., bis der User einmal mit der Seite interagiert hat. + }) +} + function RequireRight({ user, right, @@ -46,6 +93,8 @@ function RequireRight({ } function App() { + const navigate = useNavigate() + const [user, setUser] = useState(null) const [isLoading, setIsLoading] = useState(true) @@ -77,10 +126,12 @@ function App() { }) function addVisibleNotification(notification: Notification) { - if (notification.visible === false) { + if (notification.silent) { return } + playNotificationSound(notification.soundName) + setNotifications((currentNotifications) => [ notification, ...currentNotifications.filter((item) => item.id !== notification.id), @@ -151,14 +202,48 @@ function App() { dispatchEntityEvent(notification) } + function handleUserEvent(event: MessageEvent) { + const notification = parseNotificationEvent(event) + + if (!notification) { + return + } + + window.dispatchEvent( + new CustomEvent('user-notification', { + detail: notification, + }), + ) + + const updatedUser = getUserFromNotification(notification) + + if (!updatedUser) { + void loadUser() + return + } + + setUser((currentUser) => { + if (!currentUser || currentUser.id !== updatedUser.id) { + return currentUser + } + + return { + ...currentUser, + ...updatedUser, + } + }) + } + eventSource.addEventListener('notification', handleNotificationEvent) eventSource.addEventListener('operation-event', handleOperationEvent) eventSource.addEventListener('device-event', handleDeviceEvent) + eventSource.addEventListener('user-event', handleUserEvent) return () => { eventSource.removeEventListener('notification', handleNotificationEvent) eventSource.removeEventListener('operation-event', handleOperationEvent) eventSource.removeEventListener('device-event', handleDeviceEvent) + eventSource.removeEventListener('user-event', handleUserEvent) eventSource.close() } }, [user]) @@ -264,6 +349,15 @@ function App() { }).catch(() => undefined) } + function handleNotificationClick(notification: Notification) { + if ( + notification.type === 'operation.created' || + notification.entityType === 'operation' + ) { + navigate('/einsaetze') + } + } + async function handleLogout() { try { await fetch(`${API_URL}/auth/logout`, { @@ -300,101 +394,104 @@ function App() { } return ( - - } - > - - } /> + + + } + > + + } /> - - - - } - /> + + + + } + /> - - - - } - /> + + + + } + /> - - - - } - /> + + + + } + /> - - - - } - /> + + + + } + /> - - - - } - /> + + + + } + /> - } - /> + } + /> - - - - } - /> + + + + } + /> - - - - } - /> + + + + } + /> - } /> + } /> - } /> - - + } /> + + + ) } diff --git a/frontend/src/components/AddressCombobox.tsx b/frontend/src/components/AddressCombobox.tsx index fb3849c..9fb31d3 100644 --- a/frontend/src/components/AddressCombobox.tsx +++ b/frontend/src/components/AddressCombobox.tsx @@ -8,8 +8,10 @@ import { ComboboxOptions, Label, } from '@headlessui/react' +import AddressMapPicker from './AddressMapPicker' +import type { GeoJsonObject } from 'geojson' import { ChevronDownIcon } from '@heroicons/react/20/solid' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080' @@ -19,6 +21,16 @@ export type AddressSuggestion = { lat: number lon: number type?: string + geojson?: GeoJsonObject | null +} + +type Coordinates = { + lat: number + lon: number +} + +type ReverseGeocodeResponse = { + suggestion?: AddressSuggestion } type AddressComboboxProps = { @@ -28,6 +40,7 @@ type AddressComboboxProps = { value: string onChange: (value: string) => void placeholder?: string + mapVisible?: boolean } const inputClassName = @@ -37,21 +50,34 @@ function classNames(...classes: Array) { return classes.filter(Boolean).join(' ') } -function buildOpenStreetMapEmbedUrl(address: AddressSuggestion) { - const offset = 0.004 +function toNumber(value: unknown) { + if (typeof value === 'number') { + return value + } - const params = new URLSearchParams({ - bbox: [ - address.lon - offset, - address.lat - offset, - address.lon + offset, - address.lat + offset, - ].join(','), - layer: 'mapnik', - marker: `${address.lat},${address.lon}`, - }) + if (typeof value === 'string') { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : NaN + } - return `https://www.openstreetmap.org/export/embed.html?${params.toString()}` + return NaN +} + +function normalizeAddressSuggestion( + suggestion: AddressSuggestion, +): AddressSuggestion | null { + const lat = toNumber(suggestion.lat) + const lon = toNumber(suggestion.lon) + + if (!Number.isFinite(lat) || !Number.isFinite(lon)) { + return null + } + + return { + ...suggestion, + lat, + lon, + } } export default function AddressCombobox({ @@ -61,26 +87,223 @@ export default function AddressCombobox({ value, onChange, placeholder = 'Adresse suchen', + mapVisible = true, }: AddressComboboxProps) { const [query, setQuery] = useState(value) const [suggestions, setSuggestions] = useState([]) const [selectedSuggestion, setSelectedSuggestion] = useState(null) const [previewSuggestion, setPreviewSuggestion] = useState(null) + const [markerPosition, setMarkerPosition] = useState<{ + lat: number + lon: number + } | null>(null) + const [mapFocusKey, setMapFocusKey] = useState(0) const [isSearching, setIsSearching] = useState(false) const [searchError, setSearchError] = useState(null) const previousValueRef = useRef(value) + const initializedAddressRef = useRef('') + const previousMapVisibleRef = useRef(mapVisible) + + const reverseGeocodeAbortRef = useRef(null) + const internalValueUpdateRef = useRef(false) useEffect(() => { - if (value !== previousValueRef.current && value !== query) { + if (value !== previousValueRef.current) { setQuery(value) - setSelectedSuggestion(null) - setPreviewSuggestion(null) + + if (internalValueUpdateRef.current) { + internalValueUpdateRef.current = false + previousValueRef.current = value + return + } + + if (value !== query) { + initializedAddressRef.current = '' + + setSelectedSuggestion(null) + setPreviewSuggestion(null) + setMarkerPosition(null) + setMapFocusKey((currentKey) => currentKey + 1) + } } previousValueRef.current = value }, [value, query]) + useEffect(() => { + const becameVisible = mapVisible && !previousMapVisibleRef.current + + previousMapVisibleRef.current = mapVisible + + if (!becameVisible) { + return + } + + if (markerPosition) { + setMapFocusKey((currentKey) => currentKey + 1) + return + } + + initializedAddressRef.current = '' + + void loadInitialMarkerForAddress(value) + }, [mapVisible, markerPosition, value]) + + useEffect(() => { + const trimmedValue = value.trim() + + if (trimmedValue.length < 3) { + initializedAddressRef.current = '' + return + } + + if (markerPosition) { + return + } + + const abortController = new AbortController() + + void loadInitialMarkerForAddress(trimmedValue, abortController.signal) + + return () => { + abortController.abort() + } + }, [value, markerPosition]) + + async function loadInitialMarkerForAddress( + address: string, + signal?: AbortSignal, + ) { + const trimmedAddress = address.trim() + + if (trimmedAddress.length < 3) { + return + } + + if (initializedAddressRef.current === trimmedAddress && markerPosition) { + return + } + + try { + const response = await fetch( + `${API_URL}/address-search?q=${encodeURIComponent(trimmedAddress)}`, + { + credentials: 'include', + signal, + }, + ) + + if (!response.ok) { + return + } + + const data = await response.json() + + const firstSuggestion = ((data.suggestions ?? []) as AddressSuggestion[]) + .map(normalizeAddressSuggestion) + .find((suggestion): suggestion is AddressSuggestion => suggestion !== null) + + if (!firstSuggestion) { + return + } + + const reverseSuggestion = await reverseGeocodeCoordinates({ + lat: firstSuggestion.lat, + lon: firstSuggestion.lon, + }) + + const nextSuggestion: AddressSuggestion = { + ...firstSuggestion, + geojson: reverseSuggestion?.geojson ?? firstSuggestion.geojson ?? null, + } + + initializedAddressRef.current = trimmedAddress + + setSelectedSuggestion(nextSuggestion) + setPreviewSuggestion(nextSuggestion) + setMarkerPosition({ + lat: nextSuggestion.lat, + lon: nextSuggestion.lon, + }) + setMapFocusKey((currentKey) => currentKey + 1) + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + return + } + } + } + + async function reverseGeocodeCoordinates( + coordinates: Coordinates, + ): Promise { + reverseGeocodeAbortRef.current?.abort() + + const abortController = new AbortController() + reverseGeocodeAbortRef.current = abortController + + try { + const response = await fetch( + `${API_URL}/address-reverse?lat=${encodeURIComponent( + coordinates.lat, + )}&lon=${encodeURIComponent(coordinates.lon)}`, + { + credentials: 'include', + signal: abortController.signal, + }, + ) + + if (!response.ok) { + return null + } + + const data = (await response.json()) as ReverseGeocodeResponse + + if (!data.suggestion) { + return null + } + + const normalizedSuggestion = normalizeAddressSuggestion(data.suggestion) + + if (!normalizedSuggestion) { + return null + } + + return { + ...normalizedSuggestion, + lat: coordinates.lat, + lon: coordinates.lon, + } + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + return null + } + + return null + } + } + + async function handleMapPositionChange(coordinates: Coordinates) { + setMarkerPosition(coordinates) + setSelectedSuggestion(null) + setPreviewSuggestion(null) + + const suggestion = await reverseGeocodeCoordinates(coordinates) + + if (!suggestion) { + return + } + + initializedAddressRef.current = suggestion.label + internalValueUpdateRef.current = true + + setQuery(suggestion.label) + setSelectedSuggestion(suggestion) + setPreviewSuggestion(suggestion) + + onChange(suggestion.label) + } + useEffect(() => { const trimmedQuery = query.trim() @@ -113,10 +336,14 @@ export default function AddressCombobox({ } const data = await response.json() - const nextSuggestions = (data.suggestions ?? []) as AddressSuggestion[] + const nextSuggestions = ((data.suggestions ?? []) as AddressSuggestion[]) + .map(normalizeAddressSuggestion) + .filter((suggestion): suggestion is AddressSuggestion => suggestion !== null) + + const nextPreview = nextSuggestions[0] ?? null setSuggestions(nextSuggestions) - setPreviewSuggestion((currentPreview) => currentPreview ?? nextSuggestions[0] ?? null) + setPreviewSuggestion((currentPreview) => currentPreview ?? nextPreview) } catch (error) { if (error instanceof DOMException && error.name === 'AbortError') { return @@ -140,33 +367,58 @@ export default function AddressCombobox({ } }, [query]) - const mapUrl = useMemo(() => { - if (!previewSuggestion) { - return null - } - - return buildOpenStreetMapEmbedUrl(previewSuggestion) - }, [previewSuggestion]) - function handleInputChange(nextValue: string) { + reverseGeocodeAbortRef.current?.abort() + internalValueUpdateRef.current = false + initializedAddressRef.current = '' + setQuery(nextValue) setSelectedSuggestion(null) setPreviewSuggestion(null) + setMarkerPosition(null) onChange(nextValue) } - function handleSelect(suggestion: AddressSuggestion | null) { - setSelectedSuggestion(suggestion) + async function handleSelect(suggestion: AddressSuggestion | null) { + const normalizedSuggestion = suggestion + ? normalizeAddressSuggestion(suggestion) + : null - if (!suggestion) { + setSelectedSuggestion(normalizedSuggestion) + + if (!normalizedSuggestion) { return } - setQuery(suggestion.label) - setPreviewSuggestion(suggestion) - onChange(suggestion.label) + setMarkerPosition({ + lat: normalizedSuggestion.lat, + lon: normalizedSuggestion.lon, + }) + setMapFocusKey((currentKey) => currentKey + 1) + + const reverseSuggestion = await reverseGeocodeCoordinates({ + lat: normalizedSuggestion.lat, + lon: normalizedSuggestion.lon, + }) + + const nextSuggestion: AddressSuggestion = { + ...normalizedSuggestion, + label: reverseSuggestion?.label ?? normalizedSuggestion.label, + geojson: reverseSuggestion?.geojson ?? normalizedSuggestion.geojson ?? null, + } + + initializedAddressRef.current = nextSuggestion.label + internalValueUpdateRef.current = true + + setQuery(nextSuggestion.label) + setSelectedSuggestion(nextSuggestion) + setPreviewSuggestion(nextSuggestion) + + onChange(nextSuggestion.label) } + const mapKey = `${mapVisible ? 'visible' : 'hidden'}-${mapFocusKey}` + return (
@@ -196,7 +448,7 @@ export default function AddressCombobox({ {(suggestions.length > 0 || isSearching || searchError) && ( {isSearching && (
@@ -230,22 +482,14 @@ export default function AddressCombobox({
- {mapUrl && previewSuggestion && ( -
-