diff --git a/backend/.env b/backend/.env index 8fca91b..7f27cac 100644 --- a/backend/.env +++ b/backend/.env @@ -1,7 +1,7 @@ PORT=8080 DATABASE_URL=postgres://postgres:Timmy0104199%3F@localhost:5432/teg?sslmode=disable JWT_SECRET=tegvideo7010! -FRONTEND_ORIGIN=http://localhost:5173 +FRONTEND_ORIGIN=http://10.0.1.25:5173 ADDRESS_SEARCH_PROVIDER=photon ADDRESS_SEARCH_URL=https://photon.komoot.io/api ADDRESS_SEARCH_LANGUAGE=de diff --git a/backend/address_reverse.go b/backend/address_reverse.go index 4190767..cd76011 100644 --- a/backend/address_reverse.go +++ b/backend/address_reverse.go @@ -3,8 +3,10 @@ package main import ( + "context" "encoding/json" "fmt" + "math" "net/http" "net/url" "strconv" @@ -13,12 +15,13 @@ import ( ) 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"` + ID string `json:"id"` + Label string `json:"label"` + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + Type string `json:"type,omitempty"` + HasHouseNumber bool `json:"hasHouseNumber"` + GeoJSON json.RawMessage `json:"geojson,omitempty"` } type nominatimReverseAddress struct { @@ -112,6 +115,215 @@ func formatReverseAddressLabel(result nominatimReverseResponse) string { return strings.TrimSpace(result.DisplayName) } +func isPolygonGeoJSON(value json.RawMessage) bool { + if len(value) == 0 || string(value) == "null" { + return false + } + + var object struct { + Type string `json:"type"` + Geometry json.RawMessage `json:"geometry"` + Features []json.RawMessage `json:"features"` + Geometries []json.RawMessage `json:"geometries"` + } + + if err := json.Unmarshal(value, &object); err != nil { + return false + } + + switch object.Type { + case "Polygon", "MultiPolygon": + return true + case "Feature": + return isPolygonGeoJSON(object.Geometry) + case "FeatureCollection": + for _, feature := range object.Features { + if isPolygonGeoJSON(feature) { + return true + } + } + case "GeometryCollection": + for _, geometry := range object.Geometries { + if isPolygonGeoJSON(geometry) { + return true + } + } + } + + return false +} + +func coordinateDistanceMeters(firstLat, firstLon, secondLat, secondLon float64) float64 { + const earthRadius = 6371000 + + firstLatitude := firstLat * math.Pi / 180 + secondLatitude := secondLat * math.Pi / 180 + latitudeDifference := (secondLat - firstLat) * math.Pi / 180 + longitudeDifference := (secondLon - firstLon) * math.Pi / 180 + + a := math.Sin(latitudeDifference/2)*math.Sin(latitudeDifference/2) + + math.Cos(firstLatitude)*math.Cos(secondLatitude)* + math.Sin(longitudeDifference/2)*math.Sin(longitudeDifference/2) + + return earthRadius * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) +} + +type addressPolygonLookupResult struct { + GeoJSON json.RawMessage + HasHouseNumber bool +} + +func lookupAddressPolygon( + ctx context.Context, + query string, + latitude float64, + longitude float64, +) addressPolygonLookupResult { + query = strings.TrimSpace(query) + if query == "" { + return addressPolygonLookupResult{} + } + + params := url.Values{} + params.Set("format", "jsonv2") + params.Set("addressdetails", "1") + params.Set("polygon_geojson", "1") + params.Set("limit", "10") + params.Set("accept-language", "de") + params.Set("q", query) + + searchURL := fmt.Sprintf( + "https://nominatim.openstreetmap.org/search?%s", + params.Encode(), + ) + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, searchURL, nil) + if err != nil { + return addressPolygonLookupResult{} + } + + request.Header.Set("Accept", "application/json") + request.Header.Set("User-Agent", addressSearchUserAgent()) + + response, err := httpClient().Do(request) + if err != nil { + return addressPolygonLookupResult{} + } + defer response.Body.Close() + + if response.StatusCode < 200 || response.StatusCode >= 300 { + return addressPolygonLookupResult{} + } + + var results []nominatimSearchResult + if err := json.NewDecoder(response.Body).Decode(&results); err != nil { + return addressPolygonLookupResult{} + } + + var bestResult addressPolygonLookupResult + bestDistance := math.Inf(1) + + for _, result := range results { + if !isPolygonGeoJSON(result.GeoJSON) { + continue + } + + resultLatitude, latitudeErr := strconv.ParseFloat(result.Lat, 64) + resultLongitude, longitudeErr := strconv.ParseFloat(result.Lon, 64) + if latitudeErr != nil || longitudeErr != nil { + continue + } + + distance := coordinateDistanceMeters( + latitude, + longitude, + resultLatitude, + resultLongitude, + ) + if distance > 250 || distance >= bestDistance { + continue + } + + bestDistance = distance + bestResult = addressPolygonLookupResult{ + GeoJSON: result.GeoJSON, + HasHouseNumber: strings.TrimSpace(result.Address.HouseNumber) != "", + } + } + + return bestResult +} + +func normalizeOSMIdentifier(value string) string { + value = strings.ToUpper(strings.TrimSpace(value)) + value = strings.ReplaceAll(value, ":", "") + + if len(value) < 2 { + return "" + } + + if value[0] != 'N' && value[0] != 'W' && value[0] != 'R' { + return "" + } + + if _, err := strconv.ParseInt(value[1:], 10, 64); err != nil { + return "" + } + + return value +} + +func lookupOSMPolygon(ctx context.Context, osmIdentifier string) addressPolygonLookupResult { + osmIdentifier = normalizeOSMIdentifier(osmIdentifier) + if osmIdentifier == "" { + return addressPolygonLookupResult{} + } + + params := url.Values{} + params.Set("format", "jsonv2") + params.Set("polygon_geojson", "1") + params.Set("osm_ids", osmIdentifier) + + lookupURL := fmt.Sprintf( + "https://nominatim.openstreetmap.org/lookup?%s", + params.Encode(), + ) + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, lookupURL, nil) + if err != nil { + return addressPolygonLookupResult{} + } + + request.Header.Set("Accept", "application/json") + request.Header.Set("User-Agent", addressSearchUserAgent()) + + response, err := httpClient().Do(request) + if err != nil { + return addressPolygonLookupResult{} + } + defer response.Body.Close() + + if response.StatusCode < 200 || response.StatusCode >= 300 { + return addressPolygonLookupResult{} + } + + var results []nominatimSearchResult + if err := json.NewDecoder(response.Body).Decode(&results); err != nil { + return addressPolygonLookupResult{} + } + + for _, result := range results { + if isPolygonGeoJSON(result.GeoJSON) { + return addressPolygonLookupResult{ + GeoJSON: result.GeoJSON, + HasHouseNumber: strings.TrimSpace(result.Address.HouseNumber) != "", + } + } + } + + return addressPolygonLookupResult{} +} + 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")) @@ -179,14 +391,42 @@ func (s *Server) handleAddressReverse(w http.ResponseWriter, r *http.Request) { return } + geoJSON := result.GeoJSON + hasHouseNumber := strings.TrimSpace(result.Address.HouseNumber) != "" + if !isPolygonGeoJSON(geoJSON) { + lookupResult := lookupOSMPolygon( + r.Context(), + r.URL.Query().Get("osm_id"), + ) + geoJSON = lookupResult.GeoJSON + hasHouseNumber = hasHouseNumber || lookupResult.HasHouseNumber + } + + if !isPolygonGeoJSON(geoJSON) { + searchQuery := strings.TrimSpace(r.URL.Query().Get("q")) + if searchQuery == "" { + searchQuery = label + } + + lookupResult := lookupAddressPolygon( + r.Context(), + searchQuery, + lat, + lon, + ) + geoJSON = lookupResult.GeoJSON + hasHouseNumber = hasHouseNumber || lookupResult.HasHouseNumber + } + 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, + ID: fmt.Sprintf("reverse-%d-%d", result.PlaceID, time.Now().UnixNano()), + Label: label, + Lat: lat, + Lon: lon, + Type: result.Type, + HasHouseNumber: hasHouseNumber, + GeoJSON: geoJSON, }, }) } diff --git a/backend/address_search.go b/backend/address_search.go index e4a60b8..ee992e5 100644 --- a/backend/address_search.go +++ b/backend/address_search.go @@ -13,11 +13,13 @@ import ( ) type AddressSuggestion struct { - ID string `json:"id"` - Label string `json:"label"` - Lat float64 `json:"lat"` - Lon float64 `json:"lon"` - Type string `json:"type"` + ID string `json:"id"` + Label string `json:"label"` + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + Type string `json:"type"` + HasHouseNumber bool `json:"hasHouseNumber"` + GeoJSON json.RawMessage `json:"geojson,omitempty"` } type photonResponse struct { @@ -55,6 +57,7 @@ type nominatimSearchResult struct { Lon string `json:"lon"` Type string `json:"type"` Address nominatimReverseAddress `json:"address"` + GeoJSON json.RawMessage `json:"geojson,omitempty"` } func (s *Server) handleAddressSearch(w http.ResponseWriter, r *http.Request) { @@ -164,11 +167,12 @@ func (s *Server) handlePhotonAddressSearch( } suggestions = append(suggestions, AddressSuggestion{ - ID: buildPhotonID(feature.Properties, label), - Label: label, - Lat: lat, - Lon: lon, - Type: buildPhotonType(feature.Properties), + ID: buildPhotonID(feature.Properties, label), + Label: label, + Lat: lat, + Lon: lon, + Type: buildPhotonType(feature.Properties), + HasHouseNumber: strings.TrimSpace(feature.Properties.HouseNumber) != "", }) } @@ -196,6 +200,7 @@ func (s *Server) handleNominatimAddressSearch( values.Set("q", query) values.Set("format", "jsonv2") values.Set("addressdetails", "1") + values.Set("polygon_geojson", "1") values.Set("limit", strconv.Itoa(limit)) if countryCode != "" && values.Get("countrycodes") == "" { @@ -252,11 +257,13 @@ func (s *Server) handleNominatimAddressSearch( } suggestions = append(suggestions, AddressSuggestion{ - ID: strconv.FormatInt(result.PlaceID, 10), - Label: label, - Lat: lat, - Lon: lon, - Type: result.Type, + ID: strconv.FormatInt(result.PlaceID, 10), + Label: label, + Lat: lat, + Lon: lon, + Type: result.Type, + HasHouseNumber: strings.TrimSpace(result.Address.HouseNumber) != "", + GeoJSON: result.GeoJSON, }) } diff --git a/backend/admin.go b/backend/admin.go index 24010d0..028c3ef 100644 --- a/backend/admin.go +++ b/backend/admin.go @@ -24,17 +24,20 @@ type AdminTeam struct { } type AdminUser 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"` - Rights []string `json:"rights"` - Teams []AdminTeam `json:"teams"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + 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"` + Rights []string `json:"rights"` + Teams []AdminTeam `json:"teams"` + Online bool `json:"onlineStatus"` + PresenceStatus string `json:"presenceStatus"` + LastSeenAt *time.Time `json:"lastSeenAt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } type AdminUserRequest struct { @@ -120,6 +123,8 @@ func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) { COALESCE(unit, ''), COALESCE(user_group, ''), rights, + `+presenceStatusSQL+`, + last_seen_at, created_at, updated_at FROM users @@ -148,6 +153,8 @@ func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) { &user.Unit, &user.Group, &user.Rights, + &user.PresenceStatus, + &user.LastSeenAt, &user.CreatedAt, &user.UpdatedAt, ); err != nil { @@ -156,6 +163,7 @@ func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) { } user.Teams = []AdminTeam{} + user.Online = user.PresenceStatus == "online" users = append(users, user) userIndexByID[user.ID] = len(users) - 1 diff --git a/backend/channels.go b/backend/channels.go new file mode 100644 index 0000000..33659fd --- /dev/null +++ b/backend/channels.go @@ -0,0 +1,612 @@ +package main + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "errors" + "net/http" + "regexp" + "strings" + "time" + + "github.com/jackc/pgx/v5" +) + +const zabbixWebhookScript = `var params = JSON.parse(value), + request = new HttpRequest(), + payload = { + subject: params.Subject, + message: params.Message, + host: params.Host, + severity: params.Severity, + status: params.Status, + eventId: params.EventId, + eventUrl: params.EventUrl + }; + +request.addHeader('Content-Type: application/json'); +request.addHeader('Authorization: Bearer ' + params.Token); + +var response = request.post(params.URL, JSON.stringify(payload)), + status = request.getStatus(); + +if (status < 200 || status >= 300) { + throw 'Webhook failed with HTTP status ' + status + ': ' + response; +} + +return response;` + +var channelSlugPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) + +type AdminChannel struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + SourceName string `json:"sourceName"` + Image string `json:"image"` + AllUsers bool `json:"allUsers"` + UserIDs []string `json:"userIds"` + WebhookEnabled bool `json:"webhookEnabled"` + TokenConfigured bool `json:"tokenConfigured"` + WebhookTokenUpdatedAt *time.Time `json:"webhookTokenUpdatedAt"` + WebhookPath string `json:"webhookPath"` +} + +type AdminChannelUser struct { + ID string `json:"id"` + Username string `json:"username"` + DisplayName string `json:"displayName"` + Email string `json:"email"` + Unit string `json:"unit"` +} + +type saveAdminChannelRequest struct { + Name string `json:"name"` + Slug string `json:"slug"` + SourceName string `json:"sourceName"` + Image string `json:"image"` + AllUsers bool `json:"allUsers"` + UserIDs []string `json:"userIds"` + WebhookEnabled bool `json:"webhookEnabled"` +} + +type channelWebhookPayload struct { + Subject string `json:"subject"` + Message string `json:"message"` + Host string `json:"host"` + Severity string `json:"severity"` + Status string `json:"status"` + EventID string `json:"eventId"` + EventURL string `json:"eventUrl"` +} + +func (s *Server) ensureAllUserChannels(ctx context.Context, userID string) error { + _, err := s.db.Exec( + ctx, + ` + INSERT INTO conversation_members (conversation_id, user_id) + SELECT c.id, $1 + FROM conversations c + WHERE c.type = 'channel' + AND c.channel_all_users + ON CONFLICT (conversation_id, user_id) DO NOTHING + `, + userID, + ) + return err +} + +func normalizeChannelSlug(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} + +func validateAdminChannelInput(input saveAdminChannelRequest) (saveAdminChannelRequest, error) { + input.Name = strings.TrimSpace(input.Name) + input.Slug = normalizeChannelSlug(input.Slug) + input.SourceName = strings.TrimSpace(input.SourceName) + input.Image = strings.TrimSpace(input.Image) + userIDs := input.UserIDs + uniqueUserIDs := make(map[string]struct{}, len(userIDs)) + input.UserIDs = make([]string, 0, len(userIDs)) + for _, userID := range userIDs { + userID = strings.TrimSpace(userID) + if userID == "" { + continue + } + if _, exists := uniqueUserIDs[userID]; exists { + continue + } + uniqueUserIDs[userID] = struct{}{} + input.UserIDs = append(input.UserIDs, userID) + } + + if input.Name == "" { + return input, errors.New("Channelname ist erforderlich") + } + if !channelSlugPattern.MatchString(input.Slug) { + return input, errors.New("Der Webhook-Slug darf nur Kleinbuchstaben, Zahlen und Bindestriche enthalten") + } + if input.SourceName == "" { + input.SourceName = input.Name + } + if len(input.Image) > 3*1024*1024 { + return input, errors.New("Das Channel-Bild ist zu groß") + } + return input, nil +} + +func (s *Server) handleAdminListChannels(w http.ResponseWriter, r *http.Request) { + channels, err := s.listAdminChannels(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "Channels konnten nicht geladen werden") + return + } + + users, err := s.listAdminChannelUsers(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "Benutzer konnten nicht geladen werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "channels": channels, + "users": users, + "zabbixScript": zabbixWebhookScript, + }) +} + +func (s *Server) listAdminChannels(ctx context.Context) ([]AdminChannel, error) { + rows, err := s.db.Query( + ctx, + ` + SELECT + c.id::TEXT, + c.name, + c.slug, + c.channel_source_name, + c.channel_image, + c.channel_all_users, + c.webhook_enabled, + c.webhook_token_hash <> '', + c.webhook_token_updated_at, + COALESCE(array_agg(cm.user_id::TEXT) FILTER (WHERE cm.user_id IS NOT NULL), '{}') + FROM conversations c + LEFT JOIN conversation_members cm ON cm.conversation_id = c.id + WHERE c.type = 'channel' + GROUP BY c.id + ORDER BY lower(c.name) + `, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + channels := []AdminChannel{} + for rows.Next() { + var channel AdminChannel + if err := rows.Scan( + &channel.ID, + &channel.Name, + &channel.Slug, + &channel.SourceName, + &channel.Image, + &channel.AllUsers, + &channel.WebhookEnabled, + &channel.TokenConfigured, + &channel.WebhookTokenUpdatedAt, + &channel.UserIDs, + ); err != nil { + return nil, err + } + channel.WebhookPath = "/webhooks/channels/" + channel.Slug + channels = append(channels, channel) + } + return channels, rows.Err() +} + +func (s *Server) listAdminChannelUsers(ctx context.Context) ([]AdminChannelUser, error) { + rows, err := s.db.Query( + ctx, + ` + SELECT + id::TEXT, + COALESCE(username, ''), + COALESCE(display_name, ''), + email, + COALESCE(unit, '') + FROM users + ORDER BY lower(COALESCE(NULLIF(display_name, ''), username, email)) + `, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + users := []AdminChannelUser{} + for rows.Next() { + var user AdminChannelUser + if err := rows.Scan( + &user.ID, + &user.Username, + &user.DisplayName, + &user.Email, + &user.Unit, + ); err != nil { + return nil, err + } + users = append(users, user) + } + return users, rows.Err() +} + +func (s *Server) handleAdminCreateChannel(w http.ResponseWriter, r *http.Request) { + var input saveAdminChannelRequest + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Ungültige Anfrage") + return + } + + input, err := validateAdminChannelInput(input) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + tx, err := s.db.Begin(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "Channel konnte nicht erstellt werden") + return + } + defer tx.Rollback(r.Context()) + + var channelID string + err = tx.QueryRow( + r.Context(), + ` + INSERT INTO conversations ( + type, + name, + slug, + channel_source_name, + channel_image, + channel_all_users, + webhook_enabled + ) + VALUES ('channel', $1, $2, $3, $4, $5, $6) + RETURNING id::TEXT + `, + input.Name, + input.Slug, + input.SourceName, + input.Image, + input.AllUsers, + input.WebhookEnabled, + ).Scan(&channelID) + if err != nil { + writeError(w, http.StatusConflict, "Channelname oder Webhook-Slug wird bereits verwendet") + return + } + + if err := syncChannelMembers(r.Context(), tx, channelID, input.AllUsers, input.UserIDs); err != nil { + writeError(w, http.StatusInternalServerError, "Channelmitglieder konnten nicht gespeichert werden") + return + } + if err := tx.Commit(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "Channel konnte nicht gespeichert werden") + return + } + + writeJSON(w, http.StatusCreated, map[string]any{"id": channelID}) +} + +func (s *Server) handleAdminUpdateChannel(w http.ResponseWriter, r *http.Request) { + channelID := strings.TrimSpace(r.PathValue("id")) + + var input saveAdminChannelRequest + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Ungültige Anfrage") + return + } + + input, err := validateAdminChannelInput(input) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + tx, err := s.db.Begin(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "Channel konnte nicht aktualisiert werden") + return + } + defer tx.Rollback(r.Context()) + + commandTag, err := tx.Exec( + r.Context(), + ` + UPDATE conversations + SET + name = $2, + slug = $3, + channel_source_name = $4, + channel_image = $5, + channel_all_users = $6, + webhook_enabled = $7, + updated_at = now() + WHERE id = $1 + AND type = 'channel' + `, + channelID, + input.Name, + input.Slug, + input.SourceName, + input.Image, + input.AllUsers, + input.WebhookEnabled, + ) + if err != nil { + writeError(w, http.StatusConflict, "Channelname oder Webhook-Slug wird bereits verwendet") + return + } + if commandTag.RowsAffected() == 0 { + writeError(w, http.StatusNotFound, "Channel wurde nicht gefunden") + return + } + + if err := syncChannelMembers(r.Context(), tx, channelID, input.AllUsers, input.UserIDs); err != nil { + writeError(w, http.StatusInternalServerError, "Channelmitglieder konnten nicht gespeichert werden") + return + } + if err := tx.Commit(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "Channel konnte nicht gespeichert werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{"id": channelID}) +} + +func syncChannelMembers( + ctx context.Context, + tx pgx.Tx, + channelID string, + allUsers bool, + userIDs []string, +) error { + if allUsers { + _, err := tx.Exec( + ctx, + ` + INSERT INTO conversation_members (conversation_id, user_id) + SELECT $1, id FROM users + ON CONFLICT (conversation_id, user_id) DO NOTHING + `, + channelID, + ) + return err + } + + if _, err := tx.Exec( + ctx, + ` + DELETE FROM conversation_members + WHERE conversation_id = $1 + AND NOT ( + user_id::TEXT = ANY( + COALESCE($2::TEXT[], ARRAY[]::TEXT[]) + ) + ) + `, + channelID, + userIDs, + ); err != nil { + return err + } + + for _, userID := range userIDs { + if _, err := tx.Exec( + ctx, + ` + INSERT INTO conversation_members (conversation_id, user_id) + SELECT $1, id FROM users WHERE id = $2 + ON CONFLICT (conversation_id, user_id) DO NOTHING + `, + channelID, + strings.TrimSpace(userID), + ); err != nil { + return err + } + } + return nil +} + +func generateChannelWebhookToken() (string, string, error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", "", err + } + + token := base64.RawURLEncoding.EncodeToString(raw) + hash := sha256.Sum256([]byte(token)) + return token, hex.EncodeToString(hash[:]), nil +} + +func (s *Server) handleAdminRotateChannelToken(w http.ResponseWriter, r *http.Request) { + channelID := strings.TrimSpace(r.PathValue("id")) + token, tokenHash, err := generateChannelWebhookToken() + if err != nil { + writeError(w, http.StatusInternalServerError, "Webhook-Token konnte nicht erzeugt werden") + return + } + + commandTag, err := s.db.Exec( + r.Context(), + ` + UPDATE conversations + SET + webhook_token_hash = $2, + webhook_token_updated_at = now(), + updated_at = now() + WHERE id = $1 + AND type = 'channel' + `, + channelID, + tokenHash, + ) + if err != nil { + writeError(w, http.StatusInternalServerError, "Webhook-Token konnte nicht gespeichert werden") + return + } + if commandTag.RowsAffected() == 0 { + writeError(w, http.StatusNotFound, "Channel wurde nicht gefunden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{"token": token}) +} + +func (s *Server) handleChannelWebhook(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 64*1024) + slug := normalizeChannelSlug(r.PathValue("slug")) + + var channelID string + var tokenHash string + var enabled bool + err := s.db.QueryRow( + r.Context(), + ` + SELECT id::TEXT, webhook_token_hash, webhook_enabled + FROM conversations + WHERE type = 'channel' + AND slug = $1 + `, + slug, + ).Scan(&channelID, &tokenHash, &enabled) + if err != nil || !enabled || tokenHash == "" { + writeError(w, http.StatusNotFound, "Webhook wurde nicht gefunden") + return + } + + token := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) + if token == "" { + token = strings.TrimSpace(r.Header.Get("X-Webhook-Token")) + } + hash := sha256.Sum256([]byte(token)) + providedHash := hex.EncodeToString(hash[:]) + if subtle.ConstantTimeCompare([]byte(providedHash), []byte(tokenHash)) != 1 { + writeError(w, http.StatusUnauthorized, "Ungültiges Webhook-Token") + return + } + + var input channelWebhookPayload + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Ungültige Webhook-Nachricht") + return + } + + body := formatChannelWebhookMessage(input) + if body == "" { + writeError(w, http.StatusBadRequest, "Nachricht darf nicht leer sein") + return + } + + message, err := s.createChannelMessage(r.Context(), channelID, body) + if err != nil { + writeError(w, http.StatusInternalServerError, "Channel-Nachricht konnte nicht gespeichert werden") + return + } + + s.publishChatMessage(r.Context(), message, "") + writeJSON(w, http.StatusCreated, map[string]any{ + "messageId": message.ID, + "status": "accepted", + }) +} + +func formatChannelWebhookMessage(input channelWebhookPayload) string { + parts := []string{} + if subject := strings.TrimSpace(input.Subject); subject != "" { + parts = append(parts, subject) + } + if message := strings.TrimSpace(input.Message); message != "" { + parts = append(parts, message) + } + + details := []string{} + for _, detail := range []struct { + label string + value string + }{ + {"Host", input.Host}, + {"Status", input.Status}, + {"Schweregrad", input.Severity}, + {"Event-ID", input.EventID}, + {"Link", input.EventURL}, + } { + if value := strings.TrimSpace(detail.value); value != "" { + details = append(details, detail.label+": "+value) + } + } + if len(details) > 0 { + parts = append(parts, strings.Join(details, "\n")) + } + + body := strings.Join(parts, "\n\n") + runes := []rune(body) + if len(runes) > 4000 { + body = string(runes[:4000]) + } + return body +} + +func (s *Server) createChannelMessage( + ctx context.Context, + conversationID string, + body string, +) (ChatMessage, error) { + tx, err := s.db.Begin(ctx) + if err != nil { + return ChatMessage{}, err + } + defer tx.Rollback(ctx) + + var messageID string + err = tx.QueryRow( + ctx, + ` + INSERT INTO messages (conversation_id, body) + SELECT id, $2 + FROM conversations + WHERE id = $1 + AND type = 'channel' + RETURNING id::TEXT + `, + conversationID, + body, + ).Scan(&messageID) + if err != nil { + return ChatMessage{}, err + } + + if _, err := tx.Exec( + ctx, + ` + UPDATE conversations + SET last_message_at = now(), updated_at = now() + WHERE id = $1 + `, + conversationID, + ); err != nil { + return ChatMessage{}, err + } + + if err := tx.Commit(ctx); err != nil { + return ChatMessage{}, err + } + return s.getChatMessage(ctx, messageID) +} diff --git a/backend/chat.go b/backend/chat.go new file mode 100644 index 0000000..f436e2b --- /dev/null +++ b/backend/chat.go @@ -0,0 +1,1366 @@ +package main + +import ( + "context" + "database/sql" + "errors" + "math" + "net/http" + "sort" + "strings" + "time" + + "github.com/jackc/pgx/v5" +) + +type ChatUser 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"` + Online bool `json:"onlineStatus"` + PresenceStatus string `json:"presenceStatus"` + LastSeenAt *time.Time `json:"lastSeenAt"` + AwaySince *time.Time `json:"awaySince"` + LastOnlineAt *time.Time `json:"lastOnlineAt"` +} + +type ChatMessage struct { + ID string `json:"id"` + ConversationID string `json:"conversationId"` + Body string `json:"body"` + CreatedAt time.Time `json:"createdAt"` + EditedAt *time.Time `json:"editedAt"` + Sender ChatUser `json:"sender"` + Attachments []ChatAttachment `json:"attachments"` + ReplyTo *ChatMessageReference `json:"replyTo"` + ForwardedFrom *ChatMessageReference `json:"forwardedFrom"` + Location *ChatLocation `json:"location"` + LinkPreview *ChatLinkPreview `json:"linkPreview"` + replyToMessageID string + forwardedFromMessageID string +} + +type ChatLocation struct { + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` + Label string `json:"label"` +} + +type ChatLinkPreview struct { + URL string `json:"url"` + Title string `json:"title"` + Description string `json:"description"` + ImageURL string `json:"imageUrl"` + SiteName string `json:"siteName"` +} + +type ChatAttachment struct { + ID string `json:"id"` + FileName string `json:"fileName"` + ContentType string `json:"contentType"` + SizeBytes int64 `json:"sizeBytes"` + URL string `json:"url"` +} + +type ChatMessageReference struct { + ID string `json:"id"` + Body string `json:"body"` + SenderDisplayName string `json:"senderDisplayName"` + CreatedAt time.Time `json:"createdAt"` +} + +type ChatConversation struct { + ID string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Image string `json:"image"` + TeamID string `json:"teamId"` + Participants []ChatUser `json:"participants"` + LastMessage *ChatMessage `json:"lastMessage"` + LastMessageAt *time.Time `json:"lastMessageAt"` + Muted bool `json:"muted"` + UnreadCount int `json:"unreadCount"` + CreatedAt time.Time `json:"createdAt"` +} + +type createDirectConversationRequest struct { + UserID string `json:"userId"` +} + +type createChatMessageRequest struct { + Body string `json:"body"` + AttachmentIDs []string `json:"attachmentIds"` + ReplyToMessageID string `json:"replyToMessageId"` + ForwardedFromMessageID string `json:"forwardedFromMessageId"` + Location *ChatLocation `json:"location"` +} + +type createChatMessageInput struct { + Body string + AttachmentIDs []string + ReplyToMessageID string + ForwardedFromMessageID string + Location *ChatLocation +} + +var ( + errChatAccessDenied = errors.New("kein zugriff auf diesen chat") + errChatMessageEmpty = errors.New("nachricht darf nicht leer sein") + errChatMessageTooLong = errors.New("nachricht ist zu lang") + errChatLocationInvalid = errors.New("ungültiger standort") + errChatForwardSame = errors.New("nachricht kann nicht in denselben chat weitergeleitet werden") + errChatReadOnly = errors.New("channel ist schreibgeschützt") +) + +func normalizeChatLocation(location *ChatLocation) (*ChatLocation, error) { + if location == nil { + return nil, nil + } + + if math.IsNaN(location.Lat) || math.IsNaN(location.Lng) || + location.Lat < -90 || location.Lat > 90 || + location.Lng < -180 || location.Lng > 180 { + return nil, errChatLocationInvalid + } + + label := strings.TrimSpace(location.Label) + if labelRunes := []rune(label); len(labelRunes) > 200 { + label = string(labelRunes[:200]) + } + + return &ChatLocation{Lat: location.Lat, Lng: location.Lng, Label: label}, nil +} + +func (s *Server) handleListChatUsers(w http.ResponseWriter, r *http.Request) { + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + rows, err := s.db.Query( + r.Context(), + ` + SELECT + id::TEXT, + COALESCE(username, ''), + COALESCE(display_name, ''), + email, + COALESCE(avatar, ''), + COALESCE(unit, ''), + `+presenceStatusSQL+`, + NULL::TIMESTAMPTZ, + NULL::TIMESTAMPTZ, + NULL::TIMESTAMPTZ + FROM users + WHERE id <> $1 + ORDER BY + (`+presenceStatusSQL+`) = 'online' DESC, + lower(COALESCE(NULLIF(display_name, ''), username, email)) + `, + user.ID, + ) + if err != nil { + writeError(w, http.StatusInternalServerError, "Personen konnten nicht geladen werden") + return + } + defer rows.Close() + + users := []ChatUser{} + for rows.Next() { + var chatUser ChatUser + if err := rows.Scan( + &chatUser.ID, + &chatUser.Username, + &chatUser.DisplayName, + &chatUser.Email, + &chatUser.Avatar, + &chatUser.Unit, + &chatUser.PresenceStatus, + &chatUser.LastSeenAt, + &chatUser.AwaySince, + &chatUser.LastOnlineAt, + ); err != nil { + writeError(w, http.StatusInternalServerError, "Personen konnten nicht gelesen werden") + return + } + chatUser.Online = chatUser.PresenceStatus == "online" + users = append(users, chatUser) + } + + if err := rows.Err(); err != nil { + writeError(w, http.StatusInternalServerError, "Personen konnten nicht vollständig geladen werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{"users": users}) +} + +func (s *Server) ensureTeamConversations(ctx context.Context, userID string) error { + _, err := s.db.Exec( + ctx, + ` + WITH user_team_conversations AS ( + INSERT INTO conversations (type, name, team_id, created_by) + SELECT 'team', t.name, t.id, $1 + FROM teams t + JOIN user_teams current_membership + ON current_membership.team_id = t.id + AND current_membership.user_id = $1 + ON CONFLICT (team_id) WHERE team_id IS NOT NULL + DO UPDATE SET + name = EXCLUDED.name, + updated_at = now() + RETURNING id, team_id + ) + INSERT INTO conversation_members (conversation_id, user_id) + SELECT utc.id, ut.user_id + FROM user_team_conversations utc + JOIN user_teams ut ON ut.team_id = utc.team_id + ON CONFLICT (conversation_id, user_id) DO NOTHING + `, + userID, + ) + + return err +} + +func (s *Server) handleListChatConversations(w http.ResponseWriter, r *http.Request) { + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + if err := s.ensureTeamConversations(r.Context(), user.ID); err != nil { + writeError(w, http.StatusInternalServerError, "Team-Chats konnten nicht vorbereitet werden") + return + } + if err := s.ensureAllUserChannels(r.Context(), user.ID); err != nil { + writeError(w, http.StatusInternalServerError, "Channels konnten nicht vorbereitet werden") + return + } + + rows, err := s.db.Query( + r.Context(), + ` + SELECT + c.id::TEXT, + c.type, + c.name, + c.channel_image, + COALESCE(c.team_id::TEXT, ''), + c.last_message_at, + COALESCE(( + SELECT cm.muted + FROM conversation_members cm + WHERE cm.conversation_id = c.id + AND cm.user_id = $1 + ), false), + COALESCE(( + SELECT COUNT(*)::INT + FROM messages unread_message + LEFT JOIN conversation_members current_membership + ON current_membership.conversation_id = c.id + AND current_membership.user_id = $1 + WHERE unread_message.conversation_id = c.id + AND unread_message.deleted_at IS NULL + AND unread_message.sender_id IS DISTINCT FROM $1::UUID + AND unread_message.created_at > COALESCE( + current_membership.last_read_at, + '-infinity'::TIMESTAMPTZ + ) + ), 0), + c.created_at + FROM conversations c + WHERE ( + c.type = 'team' + AND EXISTS ( + SELECT 1 + FROM user_teams ut + WHERE ut.team_id = c.team_id + AND ut.user_id = $1 + ) + ) OR ( + c.type <> 'team' + AND EXISTS ( + SELECT 1 + FROM conversation_members cm + WHERE cm.conversation_id = c.id + AND cm.user_id = $1 + ) + ) + ORDER BY COALESCE(c.last_message_at, c.created_at) DESC + `, + user.ID, + ) + if err != nil { + writeError(w, http.StatusInternalServerError, "Chats konnten nicht geladen werden") + return + } + defer rows.Close() + + conversations := []ChatConversation{} + for rows.Next() { + var conversation ChatConversation + if err := rows.Scan( + &conversation.ID, + &conversation.Type, + &conversation.Name, + &conversation.Image, + &conversation.TeamID, + &conversation.LastMessageAt, + &conversation.Muted, + &conversation.UnreadCount, + &conversation.CreatedAt, + ); err != nil { + writeError(w, http.StatusInternalServerError, "Chats konnten nicht gelesen werden") + return + } + + participants, err := s.listChatParticipants(r.Context(), conversation.ID, user.ShowLastSeen) + if err != nil { + writeError(w, http.StatusInternalServerError, "Chatmitglieder konnten nicht geladen werden") + return + } + conversation.Participants = participants + + lastMessage, err := s.getLastChatMessage(r.Context(), conversation.ID) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusInternalServerError, "Letzte Nachricht konnte nicht geladen werden") + return + } + if err == nil { + conversation.LastMessage = &lastMessage + } + + conversations = append(conversations, conversation) + } + + if err := rows.Err(); err != nil { + writeError(w, http.StatusInternalServerError, "Chats konnten nicht vollständig geladen werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{"conversations": conversations}) +} + +func (s *Server) handleCreateDirectConversation(w http.ResponseWriter, r *http.Request) { + currentUser, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + var input createDirectConversationRequest + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Ungültige Anfrage") + return + } + + otherUserID := strings.TrimSpace(input.UserID) + if otherUserID == "" || otherUserID == currentUser.ID { + writeError(w, http.StatusBadRequest, "Eine andere Person ist erforderlich") + return + } + + var otherUserExists bool + if err := s.db.QueryRow( + r.Context(), + `SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)`, + otherUserID, + ).Scan(&otherUserExists); err != nil || !otherUserExists { + writeError(w, http.StatusNotFound, "Person wurde nicht gefunden") + return + } + + userIDs := []string{currentUser.ID, otherUserID} + sort.Strings(userIDs) + directKey := strings.Join(userIDs, ":") + + tx, err := s.db.Begin(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "Chat konnte nicht erstellt werden") + return + } + defer tx.Rollback(r.Context()) + + var conversationID string + err = tx.QueryRow( + r.Context(), + ` + INSERT INTO conversations (type, direct_key, created_by) + VALUES ('direct', $1, $2) + ON CONFLICT (direct_key) WHERE direct_key <> '' + DO UPDATE SET updated_at = conversations.updated_at + RETURNING id::TEXT + `, + directKey, + currentUser.ID, + ).Scan(&conversationID) + if err != nil { + writeError(w, http.StatusInternalServerError, "Chat konnte nicht erstellt werden") + return + } + + for _, userID := range userIDs { + if _, err := tx.Exec( + r.Context(), + ` + INSERT INTO conversation_members (conversation_id, user_id) + VALUES ($1, $2) + ON CONFLICT (conversation_id, user_id) DO NOTHING + `, + conversationID, + userID, + ); err != nil { + writeError(w, http.StatusInternalServerError, "Chatmitglieder konnten nicht gespeichert werden") + return + } + } + + if err := tx.Commit(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "Chat konnte nicht gespeichert werden") + return + } + + conversation, err := s.getChatConversation(r.Context(), conversationID, currentUser.ID, currentUser.ShowLastSeen) + if err != nil { + writeError(w, http.StatusInternalServerError, "Chat konnte nicht geladen werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{"conversation": conversation}) +} + +func (s *Server) handleListChatMessages(w http.ResponseWriter, r *http.Request) { + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + conversationID := strings.TrimSpace(r.PathValue("id")) + if !s.canAccessConversation(r.Context(), conversationID, user.ID) { + writeError(w, http.StatusForbidden, "Kein Zugriff auf diesen Chat") + return + } + + rows, err := s.db.Query( + r.Context(), + ` + SELECT + m.id::TEXT, + m.conversation_id::TEXT, + m.body, + m.created_at, + m.edited_at, + COALESCE(m.reply_to_message_id::TEXT, ''), + COALESCE(m.forwarded_from_message_id::TEXT, ''), + COALESCE(u.id::TEXT, ''), + COALESCE(u.username, NULLIF(c.channel_source_name, ''), c.name, ''), + COALESCE(u.display_name, NULLIF(c.channel_source_name, ''), c.name, ''), + COALESCE(u.email, ''), + COALESCE(NULLIF(u.avatar, ''), NULLIF(c.channel_image, ''), ''), + COALESCE(u.unit, ''), + CASE + WHEN u.presence_mode = 'offline' + OR u.last_seen_at IS NULL + OR u.last_seen_at < now() - INTERVAL '2 minutes' + THEN 'offline' + WHEN u.presence_mode = 'away' + OR u.last_active_at IS NULL + OR u.last_active_at < now() - make_interval(mins => u.away_after_minutes) + THEN 'away' + ELSE 'online' + END, + CASE + WHEN c.type = 'direct' AND u.show_last_seen + THEN u.last_seen_at + ELSE NULL + END, + CASE + WHEN c.type <> 'direct' OR NOT u.show_last_seen THEN NULL + WHEN u.presence_mode = 'away' THEN u.presence_mode_updated_at + WHEN u.presence_mode = 'online' + AND u.last_seen_at >= now() - INTERVAL '2 minutes' + AND u.last_active_at < now() - make_interval(mins => u.away_after_minutes) + THEN u.last_active_at + make_interval(mins => u.away_after_minutes) + ELSE NULL + END, + CASE + WHEN c.type = 'direct' AND u.show_last_seen + THEN CASE + WHEN u.presence_mode = 'offline' THEN u.presence_mode_updated_at + ELSE u.last_seen_at + END + ELSE NULL + END + FROM messages m + JOIN conversations c ON c.id = m.conversation_id + LEFT JOIN users u ON u.id = m.sender_id + WHERE m.conversation_id = $1 + AND m.deleted_at IS NULL + ORDER BY m.created_at ASC, m.id ASC + LIMIT 200 + `, + conversationID, + ) + if err != nil { + writeError(w, http.StatusInternalServerError, "Nachrichten konnten nicht geladen werden") + return + } + defer rows.Close() + + messages := []ChatMessage{} + for rows.Next() { + message, err := scanChatMessage(rows) + if err != nil { + writeError(w, http.StatusInternalServerError, "Nachrichten konnten nicht gelesen werden") + return + } + if err := s.enrichChatMessage(r.Context(), &message); err != nil { + writeError(w, http.StatusInternalServerError, "Nachrichtendetails konnten nicht geladen werden") + return + } + messages = append(messages, message) + } + + if err := rows.Err(); err != nil { + writeError(w, http.StatusInternalServerError, "Nachrichten konnten nicht vollständig geladen werden") + return + } + + _ = s.markChatConversationRead(r.Context(), conversationID, user.ID) + + // Gegenseitigkeit: Wer selbst nichts teilt, bekommt auch keine Status-Details. + if !user.ShowLastSeen { + for i := range messages { + messages[i].Sender.LastSeenAt = nil + messages[i].Sender.AwaySince = nil + messages[i].Sender.LastOnlineAt = nil + } + } + + writeJSON(w, http.StatusOK, map[string]any{"messages": messages}) +} + +func (s *Server) handleCreateChatMessage(w http.ResponseWriter, r *http.Request) { + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + conversationID := strings.TrimSpace(r.PathValue("id")) + + var input createChatMessageRequest + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Ungültige Anfrage") + return + } + + message, err := s.createChatMessage( + r.Context(), + user, + conversationID, + createChatMessageInput{ + Body: input.Body, + AttachmentIDs: input.AttachmentIDs, + ReplyToMessageID: input.ReplyToMessageID, + ForwardedFromMessageID: input.ForwardedFromMessageID, + Location: input.Location, + }, + ) + if err != nil { + writeChatMessageError(w, err) + return + } + + s.publishChatMessage(r.Context(), message, "") + writeJSON(w, http.StatusCreated, map[string]any{"message": message}) +} + +func (s *Server) createChatMessage( + ctx context.Context, + user User, + conversationID string, + input createChatMessageInput, +) (ChatMessage, error) { + var message ChatMessage + + conversationID = strings.TrimSpace(conversationID) + if !s.canAccessConversation(ctx, conversationID, user.ID) { + return message, errChatAccessDenied + } + if !s.isConversationWritable(ctx, conversationID) { + return message, errChatReadOnly + } + + if replyToMessageID := strings.TrimSpace(input.ReplyToMessageID); replyToMessageID != "" { + var valid bool + err := s.db.QueryRow( + ctx, + ` + SELECT EXISTS( + SELECT 1 + FROM messages + WHERE id = $1 + AND conversation_id = $2 + AND deleted_at IS NULL + ) + `, + replyToMessageID, + conversationID, + ).Scan(&valid) + if err != nil { + return message, err + } + if !valid { + return message, errChatAccessDenied + } + } + + if forwardedFromMessageID := strings.TrimSpace(input.ForwardedFromMessageID); forwardedFromMessageID != "" { + var sourceConversationID string + err := s.db.QueryRow( + ctx, + ` + SELECT conversation_id::TEXT + FROM messages + WHERE id = $1 + AND deleted_at IS NULL + `, + forwardedFromMessageID, + ).Scan(&sourceConversationID) + if err != nil || !s.canAccessConversation(ctx, sourceConversationID, user.ID) { + return message, errChatAccessDenied + } + if sourceConversationID == conversationID { + return message, errChatForwardSame + } + } + + location, err := normalizeChatLocation(input.Location) + if err != nil { + return message, err + } + + body := strings.TrimSpace(input.Body) + if body == "" && + len(input.AttachmentIDs) == 0 && + strings.TrimSpace(input.ForwardedFromMessageID) == "" && + location == nil { + return message, errChatMessageEmpty + } + if len([]rune(body)) > 4000 { + return message, errChatMessageTooLong + } + + var locationLat, locationLng *float64 + locationLabel := "" + if location != nil { + locationLat = &location.Lat + locationLng = &location.Lng + locationLabel = location.Label + } + + tx, err := s.db.Begin(ctx) + if err != nil { + return message, err + } + defer tx.Rollback(ctx) + + var messageID string + err = tx.QueryRow( + ctx, + ` + INSERT INTO messages ( + conversation_id, + sender_id, + body, + reply_to_message_id, + forwarded_from_message_id, + location_lat, + location_lng, + location_label + ) + VALUES ( + $1, + $2, + $3, + NULLIF($4, '')::UUID, + NULLIF($5, '')::UUID, + $6, + $7, + $8 + ) + RETURNING id::TEXT + `, + conversationID, + user.ID, + body, + strings.TrimSpace(input.ReplyToMessageID), + strings.TrimSpace(input.ForwardedFromMessageID), + locationLat, + locationLng, + locationLabel, + ).Scan(&messageID) + if err != nil { + return message, err + } + + if _, err := tx.Exec( + ctx, + ` + UPDATE conversations + SET last_message_at = now(), updated_at = now() + WHERE id = $1 + `, + conversationID, + ); err != nil { + return message, err + } + + for _, attachmentID := range input.AttachmentIDs { + commandTag, err := tx.Exec( + ctx, + ` + UPDATE message_attachments + SET message_id = $1 + WHERE id = $2 + AND uploader_id = $3 + AND message_id IS NULL + `, + messageID, + strings.TrimSpace(attachmentID), + user.ID, + ) + if err != nil { + return message, err + } + if commandTag.RowsAffected() == 0 { + return message, errors.New("anhang wurde nicht gefunden") + } + } + + if forwardedFromMessageID := strings.TrimSpace(input.ForwardedFromMessageID); forwardedFromMessageID != "" { + if _, err := tx.Exec( + ctx, + ` + INSERT INTO message_attachments ( + message_id, + uploader_id, + file_name, + content_type, + size_bytes, + data + ) + SELECT + $1, + $2, + file_name, + content_type, + size_bytes, + data + FROM message_attachments + WHERE message_id = $3 + ORDER BY created_at ASC + `, + messageID, + user.ID, + forwardedFromMessageID, + ); err != nil { + return message, err + } + } + + if _, err := tx.Exec( + ctx, + ` + UPDATE conversation_members + SET last_read_at = now() + WHERE conversation_id = $1 AND user_id = $2 + `, + conversationID, + user.ID, + ); err != nil { + return message, err + } + + if err := tx.Commit(ctx); err != nil { + return message, err + } + + message, err = s.getChatMessage(ctx, messageID) + if err != nil { + return message, err + } + + return message, nil +} + +func writeChatMessageError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, errChatAccessDenied): + writeError(w, http.StatusForbidden, "Kein Zugriff auf diesen Chat") + case errors.Is(err, errChatMessageEmpty): + writeError(w, http.StatusBadRequest, "Nachricht darf nicht leer sein") + case errors.Is(err, errChatMessageTooLong): + writeError(w, http.StatusBadRequest, "Nachricht darf höchstens 4000 Zeichen enthalten") + case errors.Is(err, errChatLocationInvalid): + writeError(w, http.StatusBadRequest, "Ungültiger Standort") + case errors.Is(err, errChatForwardSame): + writeError(w, http.StatusBadRequest, "Nachrichten können nicht in denselben Chat weitergeleitet werden") + case errors.Is(err, errChatReadOnly): + writeError(w, http.StatusForbidden, "Channels sind schreibgeschützt") + default: + writeError(w, http.StatusInternalServerError, "Nachricht konnte nicht gesendet werden") + } +} + +func (s *Server) canAccessConversation(ctx context.Context, conversationID string, userID string) bool { + if conversationID == "" || userID == "" { + return false + } + + var allowed bool + err := s.db.QueryRow( + ctx, + ` + SELECT EXISTS( + SELECT 1 + FROM conversations c + WHERE c.id = $1 + AND ( + ( + c.type = 'team' + AND EXISTS ( + SELECT 1 + FROM user_teams ut + WHERE ut.team_id = c.team_id + AND ut.user_id = $2 + ) + ) + OR ( + c.type <> 'team' + AND EXISTS ( + SELECT 1 + FROM conversation_members cm + WHERE cm.conversation_id = c.id + AND cm.user_id = $2 + ) + ) + ) + ) + `, + conversationID, + userID, + ).Scan(&allowed) + + return err == nil && allowed +} + +func (s *Server) isConversationWritable(ctx context.Context, conversationID string) bool { + var writable bool + err := s.db.QueryRow( + ctx, + ` + SELECT type <> 'channel' + FROM conversations + WHERE id = $1 + `, + conversationID, + ).Scan(&writable) + return err == nil && writable +} + +func (s *Server) listChatParticipants(ctx context.Context, conversationID string, viewerShowLastSeen bool) ([]ChatUser, error) { + rows, err := s.db.Query( + ctx, + ` + SELECT + u.id::TEXT, + COALESCE(u.username, NULLIF(c.channel_source_name, ''), c.name, ''), + COALESCE(u.display_name, NULLIF(c.channel_source_name, ''), c.name, ''), + u.email, + COALESCE(NULLIF(u.avatar, ''), NULLIF(c.channel_image, ''), ''), + COALESCE(u.unit, ''), + CASE + WHEN u.presence_mode = 'offline' + OR u.last_seen_at IS NULL + OR u.last_seen_at < now() - INTERVAL '2 minutes' + THEN 'offline' + WHEN u.presence_mode = 'away' + OR u.last_active_at IS NULL + OR u.last_active_at < now() - make_interval(mins => u.away_after_minutes) + THEN 'away' + ELSE 'online' + END, + CASE + WHEN c.type = 'direct' AND u.show_last_seen + THEN u.last_seen_at + ELSE NULL + END, + CASE + WHEN c.type <> 'direct' OR NOT u.show_last_seen THEN NULL + WHEN u.presence_mode = 'away' THEN u.presence_mode_updated_at + WHEN u.presence_mode = 'online' + AND u.last_seen_at >= now() - INTERVAL '2 minutes' + AND u.last_active_at < now() - make_interval(mins => u.away_after_minutes) + THEN u.last_active_at + make_interval(mins => u.away_after_minutes) + ELSE NULL + END, + CASE + WHEN c.type = 'direct' AND u.show_last_seen + THEN CASE + WHEN u.presence_mode = 'offline' THEN u.presence_mode_updated_at + ELSE u.last_seen_at + END + ELSE NULL + END + FROM conversations c + JOIN users u ON ( + ( + c.type = 'team' + AND EXISTS ( + SELECT 1 + FROM user_teams ut + WHERE ut.team_id = c.team_id + AND ut.user_id = u.id + ) + ) + OR ( + c.type <> 'team' + AND EXISTS ( + SELECT 1 + FROM conversation_members cm + WHERE cm.conversation_id = c.id + AND cm.user_id = u.id + ) + ) + ) + WHERE c.id = $1 + ORDER BY lower(COALESCE(NULLIF(u.display_name, ''), u.username, u.email)) + `, + conversationID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + participants := []ChatUser{} + for rows.Next() { + var participant ChatUser + if err := rows.Scan( + &participant.ID, + &participant.Username, + &participant.DisplayName, + &participant.Email, + &participant.Avatar, + &participant.Unit, + &participant.PresenceStatus, + &participant.LastSeenAt, + &participant.AwaySince, + &participant.LastOnlineAt, + ); err != nil { + return nil, err + } + participant.Online = participant.PresenceStatus == "online" + participants = append(participants, participant) + } + + // Gegenseitigkeit: Wer den eigenen letzten Online-Zeitpunkt nicht teilt, + // sieht auch bei anderen keine Status-Details (zuletzt online / Abwesenheitsdauer). + if !viewerShowLastSeen { + for i := range participants { + participants[i].LastSeenAt = nil + participants[i].AwaySince = nil + participants[i].LastOnlineAt = nil + } + } + + return participants, rows.Err() +} + +func (s *Server) getChatConversation(ctx context.Context, conversationID string, userID string, viewerShowLastSeen bool) (ChatConversation, error) { + var conversation ChatConversation + err := s.db.QueryRow( + ctx, + ` + SELECT + c.id::TEXT, + c.type, + c.name, + c.channel_image, + COALESCE(c.team_id::TEXT, ''), + c.last_message_at, + COALESCE(( + SELECT cm.muted + FROM conversation_members cm + WHERE cm.conversation_id = c.id + AND cm.user_id = $2 + ), false), + COALESCE(( + SELECT COUNT(*)::INT + FROM messages unread_message + LEFT JOIN conversation_members current_membership + ON current_membership.conversation_id = c.id + AND current_membership.user_id = $2 + WHERE unread_message.conversation_id = c.id + AND unread_message.deleted_at IS NULL + AND unread_message.sender_id IS DISTINCT FROM $2::UUID + AND unread_message.created_at > COALESCE( + current_membership.last_read_at, + '-infinity'::TIMESTAMPTZ + ) + ), 0), + c.created_at + FROM conversations c + WHERE c.id = $1 + `, + conversationID, + userID, + ).Scan( + &conversation.ID, + &conversation.Type, + &conversation.Name, + &conversation.Image, + &conversation.TeamID, + &conversation.LastMessageAt, + &conversation.Muted, + &conversation.UnreadCount, + &conversation.CreatedAt, + ) + if err != nil { + return conversation, err + } + if !s.canAccessConversation(ctx, conversation.ID, userID) { + return conversation, pgx.ErrNoRows + } + + conversation.Participants, err = s.listChatParticipants(ctx, conversation.ID, viewerShowLastSeen) + if err != nil { + return conversation, err + } + + lastMessage, err := s.getLastChatMessage(ctx, conversation.ID) + if err == nil { + conversation.LastMessage = &lastMessage + } else if !errors.Is(err, pgx.ErrNoRows) { + return conversation, err + } + + return conversation, nil +} + +type chatMessageScanner interface { + Scan(dest ...any) error +} + +func scanChatMessage(scanner chatMessageScanner) (ChatMessage, error) { + var message ChatMessage + err := scanner.Scan( + &message.ID, + &message.ConversationID, + &message.Body, + &message.CreatedAt, + &message.EditedAt, + &message.replyToMessageID, + &message.forwardedFromMessageID, + &message.Sender.ID, + &message.Sender.Username, + &message.Sender.DisplayName, + &message.Sender.Email, + &message.Sender.Avatar, + &message.Sender.Unit, + &message.Sender.PresenceStatus, + &message.Sender.LastSeenAt, + &message.Sender.AwaySince, + &message.Sender.LastOnlineAt, + ) + message.Sender.Online = message.Sender.PresenceStatus == "online" + return message, err +} + +func (s *Server) getChatMessage(ctx context.Context, messageID string) (ChatMessage, error) { + row := s.db.QueryRow( + ctx, + ` + SELECT + m.id::TEXT, + m.conversation_id::TEXT, + m.body, + m.created_at, + m.edited_at, + COALESCE(m.reply_to_message_id::TEXT, ''), + COALESCE(m.forwarded_from_message_id::TEXT, ''), + COALESCE(u.id::TEXT, ''), + COALESCE(u.username, NULLIF(c.channel_source_name, ''), c.name, ''), + COALESCE(u.display_name, NULLIF(c.channel_source_name, ''), c.name, ''), + COALESCE(u.email, ''), + COALESCE(NULLIF(u.avatar, ''), NULLIF(c.channel_image, ''), ''), + COALESCE(u.unit, ''), + CASE + WHEN u.presence_mode = 'offline' + OR u.last_seen_at IS NULL + OR u.last_seen_at < now() - INTERVAL '2 minutes' + THEN 'offline' + WHEN u.presence_mode = 'away' + OR u.last_active_at IS NULL + OR u.last_active_at < now() - make_interval(mins => u.away_after_minutes) + THEN 'away' + ELSE 'online' + END, + CASE + WHEN c.type = 'direct' AND u.show_last_seen + THEN u.last_seen_at + ELSE NULL + END, + CASE + WHEN c.type <> 'direct' OR NOT u.show_last_seen THEN NULL + WHEN u.presence_mode = 'away' THEN u.presence_mode_updated_at + WHEN u.presence_mode = 'online' + AND u.last_seen_at >= now() - INTERVAL '2 minutes' + AND u.last_active_at < now() - make_interval(mins => u.away_after_minutes) + THEN u.last_active_at + make_interval(mins => u.away_after_minutes) + ELSE NULL + END, + CASE + WHEN c.type = 'direct' AND u.show_last_seen + THEN CASE + WHEN u.presence_mode = 'offline' THEN u.presence_mode_updated_at + ELSE u.last_seen_at + END + ELSE NULL + END + FROM messages m + JOIN conversations c ON c.id = m.conversation_id + LEFT JOIN users u ON u.id = m.sender_id + WHERE m.id = $1 + `, + messageID, + ) + message, err := scanChatMessage(row) + if err != nil { + return message, err + } + err = s.enrichChatMessage(ctx, &message) + return message, err +} + +func (s *Server) getLastChatMessage(ctx context.Context, conversationID string) (ChatMessage, error) { + row := s.db.QueryRow( + ctx, + ` + SELECT + m.id::TEXT, + m.conversation_id::TEXT, + m.body, + m.created_at, + m.edited_at, + COALESCE(m.reply_to_message_id::TEXT, ''), + COALESCE(m.forwarded_from_message_id::TEXT, ''), + COALESCE(u.id::TEXT, ''), + COALESCE(u.username, NULLIF(c.channel_source_name, ''), c.name, ''), + COALESCE(u.display_name, NULLIF(c.channel_source_name, ''), c.name, ''), + COALESCE(u.email, ''), + COALESCE(NULLIF(u.avatar, ''), NULLIF(c.channel_image, ''), ''), + COALESCE(u.unit, ''), + CASE + WHEN u.presence_mode = 'offline' + OR u.last_seen_at IS NULL + OR u.last_seen_at < now() - INTERVAL '2 minutes' + THEN 'offline' + WHEN u.presence_mode = 'away' + OR u.last_active_at IS NULL + OR u.last_active_at < now() - make_interval(mins => u.away_after_minutes) + THEN 'away' + ELSE 'online' + END, + CASE + WHEN c.type = 'direct' AND u.show_last_seen + THEN u.last_seen_at + ELSE NULL + END, + CASE + WHEN c.type <> 'direct' OR NOT u.show_last_seen THEN NULL + WHEN u.presence_mode = 'away' THEN u.presence_mode_updated_at + WHEN u.presence_mode = 'online' + AND u.last_seen_at >= now() - INTERVAL '2 minutes' + AND u.last_active_at < now() - make_interval(mins => u.away_after_minutes) + THEN u.last_active_at + make_interval(mins => u.away_after_minutes) + ELSE NULL + END, + CASE + WHEN c.type = 'direct' AND u.show_last_seen + THEN CASE + WHEN u.presence_mode = 'offline' THEN u.presence_mode_updated_at + ELSE u.last_seen_at + END + ELSE NULL + END + FROM messages m + JOIN conversations c ON c.id = m.conversation_id + LEFT JOIN users u ON u.id = m.sender_id + WHERE m.conversation_id = $1 + AND m.deleted_at IS NULL + ORDER BY m.created_at DESC, m.id DESC + LIMIT 1 + `, + conversationID, + ) + message, err := scanChatMessage(row) + if err != nil { + return message, err + } + err = s.enrichChatMessage(ctx, &message) + return message, err +} + +func (s *Server) enrichChatMessage(ctx context.Context, message *ChatMessage) error { + attachments, err := s.listChatAttachments(ctx, message.ID) + if err != nil { + return err + } + message.Attachments = attachments + + location, err := s.getChatMessageLocation(ctx, message.ID) + if err != nil { + return err + } + message.Location = location + + preview, err := s.getChatLinkPreview(ctx, message.ID) + if err != nil { + return err + } + message.LinkPreview = preview + + if message.replyToMessageID != "" { + reference, err := s.getChatMessageReference(ctx, message.replyToMessageID) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return err + } + if err == nil { + message.ReplyTo = &reference + } + } + + if message.forwardedFromMessageID != "" { + reference, err := s.getChatMessageReference(ctx, message.forwardedFromMessageID) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return err + } + if err == nil { + message.ForwardedFrom = &reference + } + } + + return nil +} + +func (s *Server) getChatMessageLocation( + ctx context.Context, + messageID string, +) (*ChatLocation, error) { + var lat, lng sql.NullFloat64 + var label string + + err := s.db.QueryRow( + ctx, + `SELECT location_lat, location_lng, COALESCE(location_label, '') FROM messages WHERE id = $1`, + messageID, + ).Scan(&lat, &lng, &label) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + + if !lat.Valid || !lng.Valid { + return nil, nil + } + + return &ChatLocation{Lat: lat.Float64, Lng: lng.Float64, Label: label}, nil +} + +func (s *Server) listChatAttachments( + ctx context.Context, + messageID string, +) ([]ChatAttachment, error) { + rows, err := s.db.Query( + ctx, + ` + SELECT id::TEXT, file_name, content_type, size_bytes + FROM message_attachments + WHERE message_id = $1 + ORDER BY created_at ASC + `, + messageID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + attachments := []ChatAttachment{} + for rows.Next() { + var attachment ChatAttachment + if err := rows.Scan( + &attachment.ID, + &attachment.FileName, + &attachment.ContentType, + &attachment.SizeBytes, + ); err != nil { + return nil, err + } + attachment.URL = "/chat/attachments/" + attachment.ID + attachments = append(attachments, attachment) + } + + return attachments, rows.Err() +} + +func (s *Server) getChatMessageReference( + ctx context.Context, + messageID string, +) (ChatMessageReference, error) { + var reference ChatMessageReference + + err := s.db.QueryRow( + ctx, + ` + SELECT + m.id::TEXT, + m.body, + COALESCE( + NULLIF(u.display_name, ''), + NULLIF(u.username, ''), + u.email, + NULLIF(c.channel_source_name, ''), + c.name, + 'Gelöschter Benutzer' + ), + m.created_at + FROM messages m + JOIN conversations c ON c.id = m.conversation_id + LEFT JOIN users u ON u.id = m.sender_id + WHERE m.id = $1 + AND m.deleted_at IS NULL + `, + messageID, + ).Scan( + &reference.ID, + &reference.Body, + &reference.SenderDisplayName, + &reference.CreatedAt, + ) + + return reference, err +} diff --git a/backend/chat_attachments.go b/backend/chat_attachments.go new file mode 100644 index 0000000..5e06b72 --- /dev/null +++ b/backend/chat_attachments.go @@ -0,0 +1,160 @@ +package main + +import ( + "fmt" + "io" + "mime" + "net/http" + "path/filepath" + "strings" +) + +const maxChatAttachmentSize = 10 << 20 + +func (s *Server) handleUploadChatAttachment(w http.ResponseWriter, r *http.Request) { + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + r.Body = http.MaxBytesReader(w, r.Body, maxChatAttachmentSize+(1<<20)) + if err := r.ParseMultipartForm(maxChatAttachmentSize); err != nil { + writeError(w, http.StatusBadRequest, "Datei ist zu groß oder ungültig") + return + } + + file, header, err := r.FormFile("file") + if err != nil { + writeError(w, http.StatusBadRequest, "Datei fehlt") + return + } + defer file.Close() + + data, err := io.ReadAll(io.LimitReader(file, maxChatAttachmentSize+1)) + if err != nil { + writeError(w, http.StatusBadRequest, "Datei konnte nicht gelesen werden") + return + } + if len(data) == 0 { + writeError(w, http.StatusBadRequest, "Datei ist leer") + return + } + if len(data) > maxChatAttachmentSize { + writeError(w, http.StatusBadRequest, "Datei darf höchstens 10 MB groß sein") + return + } + + fileName := strings.TrimSpace(filepath.Base(header.Filename)) + if fileName == "" || fileName == "." { + fileName = "Anhang" + } + + contentType := strings.TrimSpace(header.Header.Get("Content-Type")) + if contentType == "" { + contentType = http.DetectContentType(data) + } + + var attachment ChatAttachment + err = s.db.QueryRow( + r.Context(), + ` + INSERT INTO message_attachments ( + uploader_id, + file_name, + content_type, + size_bytes, + data + ) + VALUES ($1, $2, $3, $4, $5) + RETURNING id::TEXT, file_name, content_type, size_bytes + `, + user.ID, + fileName, + contentType, + len(data), + data, + ).Scan( + &attachment.ID, + &attachment.FileName, + &attachment.ContentType, + &attachment.SizeBytes, + ) + if err != nil { + writeError(w, http.StatusInternalServerError, "Datei konnte nicht gespeichert werden") + return + } + + attachment.URL = "/chat/attachments/" + attachment.ID + writeJSON(w, http.StatusCreated, map[string]any{"attachment": attachment}) +} + +func (s *Server) handleDownloadChatAttachment(w http.ResponseWriter, r *http.Request) { + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + attachmentID := strings.TrimSpace(r.PathValue("id")) + var uploaderID string + var messageID string + var conversationID string + var fileName string + var contentType string + var data []byte + + err := s.db.QueryRow( + r.Context(), + ` + SELECT + a.uploader_id::TEXT, + COALESCE(a.message_id::TEXT, ''), + COALESCE(m.conversation_id::TEXT, ''), + a.file_name, + a.content_type, + a.data + FROM message_attachments a + LEFT JOIN messages m ON m.id = a.message_id + WHERE a.id = $1 + `, + attachmentID, + ).Scan( + &uploaderID, + &messageID, + &conversationID, + &fileName, + &contentType, + &data, + ) + if err != nil { + writeError(w, http.StatusNotFound, "Datei wurde nicht gefunden") + return + } + + allowed := messageID == "" && uploaderID == user.ID + if messageID != "" { + allowed = s.canAccessConversation(r.Context(), conversationID, user.ID) + } + if !allowed { + writeError(w, http.StatusForbidden, "Kein Zugriff auf diese Datei") + return + } + + if contentType == "" { + contentType = "application/octet-stream" + } + + dispositionType := "attachment" + if strings.HasPrefix(strings.ToLower(contentType), "image/") { + dispositionType = "inline" + } + disposition := mime.FormatMediaType(dispositionType, map[string]string{ + "filename": fileName, + }) + w.Header().Set("Content-Type", contentType) + w.Header().Set("Content-Disposition", disposition) + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data))) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) +} diff --git a/backend/chat_link_preview.go b/backend/chat_link_preview.go new file mode 100644 index 0000000..c3e2139 --- /dev/null +++ b/backend/chat_link_preview.go @@ -0,0 +1,361 @@ +package main + +import ( + "context" + "errors" + "html" + "io" + "net" + "net/http" + "net/url" + "regexp" + "strings" + "syscall" + "time" + + "github.com/jackc/pgx/v5" +) + +const ( + maxLinkPreviewBytes = 512 * 1024 + linkPreviewUserAgent = "Mozilla/5.0 (compatible; TEG-LinkPreview/1.0)" +) + +var ( + errLinkPreviewBlocked = errors.New("zieladresse ist nicht erlaubt") + + chatURLPattern = regexp.MustCompile(`https?://[^\s<>"']+`) + metaTagPattern = regexp.MustCompile(`(?is)]*>`) + metaAttrPattern = regexp.MustCompile(`(?is)([a-zA-Z:_-]+)\s*=\s*"([^"]*)"|([a-zA-Z:_-]+)\s*=\s*'([^']*)'`) + titleTagPattern = regexp.MustCompile(`(?is)]*>(.*?)`) + htmlTagPattern = regexp.MustCompile(`(?is)<[^>]+>`) + trailingTrimSet = ".,!?;:)]}'\"" +) + +// firstHTTPURL liefert die erste http(s)-URL aus dem Nachrichtentext. +func firstHTTPURL(body string) string { + match := chatURLPattern.FindString(body) + if match == "" { + return "" + } + return strings.TrimRight(match, trailingTrimSet) +} + +// isBlockedIP verhindert SSRF: interne/lokale Adressbereiche sind tabu. +func isBlockedIP(ip net.IP) bool { + if ip == nil { + return true + } + if ip4 := ip.To4(); ip4 != nil { + ip = ip4 + } + return ip.IsLoopback() || + ip.IsPrivate() || + ip.IsUnspecified() || + ip.IsLinkLocalUnicast() || + ip.IsLinkLocalMulticast() || + ip.IsInterfaceLocalMulticast() || + ip.IsMulticast() +} + +// safeDialControl wird unmittelbar vor dem Verbindungsaufbau mit der bereits +// aufgelösten Ziel-IP aufgerufen – das blockt auch DNS-Rebinding. +func safeDialControl(network, address string, _ syscall.RawConn) error { + switch network { + case "tcp", "tcp4", "tcp6": + default: + return errLinkPreviewBlocked + } + + host, _, err := net.SplitHostPort(address) + if err != nil { + return errLinkPreviewBlocked + } + if isBlockedIP(net.ParseIP(host)) { + return errLinkPreviewBlocked + } + return nil +} + +func newLinkPreviewClient() *http.Client { + dialer := &net.Dialer{ + Timeout: 5 * time.Second, + Control: safeDialControl, + } + + transport := &http.Transport{ + DialContext: dialer.DialContext, + TLSHandshakeTimeout: 5 * time.Second, + ResponseHeaderTimeout: 5 * time.Second, + DisableKeepAlives: true, + } + + return &http.Client{ + Timeout: 10 * time.Second, + Transport: transport, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 3 { + return errLinkPreviewBlocked + } + if req.URL.Scheme != "http" && req.URL.Scheme != "https" { + return errLinkPreviewBlocked + } + return nil + }, + } +} + +func fetchLinkPreview(ctx context.Context, rawURL string) (*ChatLinkPreview, error) { + parsed, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return nil, errLinkPreviewBlocked + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", linkPreviewUserAgent) + req.Header.Set("Accept", "text/html,application/xhtml+xml") + req.Header.Set("Accept-Language", "de,en;q=0.8") + + resp, err := newLinkPreviewClient().Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, errLinkPreviewBlocked + } + + contentType := strings.ToLower(resp.Header.Get("Content-Type")) + if contentType != "" && !strings.Contains(contentType, "html") { + return nil, errLinkPreviewBlocked + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxLinkPreviewBytes)) + if err != nil { + return nil, err + } + + preview := parseOpenGraph(string(body), resp.Request.URL) + preview.URL = rawURL + return preview, nil +} + +func parseOpenGraph(htmlBody string, baseURL *url.URL) *ChatLinkPreview { + meta := map[string]string{} + + for _, tag := range metaTagPattern.FindAllString(htmlBody, 300) { + var key, content string + hasContent := false + + for _, attr := range metaAttrPattern.FindAllStringSubmatch(tag, -1) { + name := strings.ToLower(attr[1]) + value := attr[2] + if name == "" { + name = strings.ToLower(attr[3]) + value = attr[4] + } + value = html.UnescapeString(value) + + switch name { + case "property", "name": + key = strings.ToLower(strings.TrimSpace(value)) + case "content": + content = value + hasContent = true + } + } + + if key != "" && hasContent { + if _, exists := meta[key]; !exists { + meta[key] = content + } + } + } + + pick := func(keys ...string) string { + for _, key := range keys { + if value := strings.TrimSpace(meta[key]); value != "" { + return value + } + } + return "" + } + + preview := &ChatLinkPreview{} + + preview.Title = pick("og:title", "twitter:title") + if preview.Title == "" { + if match := titleTagPattern.FindStringSubmatch(htmlBody); len(match) > 1 { + preview.Title = strings.TrimSpace( + html.UnescapeString(htmlTagPattern.ReplaceAllString(match[1], "")), + ) + } + } + + preview.Description = pick("og:description", "twitter:description", "description") + preview.SiteName = pick("og:site_name") + if preview.SiteName == "" && baseURL != nil { + preview.SiteName = baseURL.Hostname() + } + + if image := pick("og:image", "og:image:url", "og:image:secure_url", "twitter:image", "twitter:image:src"); image != "" { + preview.ImageURL = resolveImageURL(image, baseURL) + } + + preview.Title = truncateRunes(preview.Title, 200) + preview.Description = truncateRunes(preview.Description, 400) + preview.SiteName = truncateRunes(preview.SiteName, 100) + + return preview +} + +func resolveImageURL(image string, baseURL *url.URL) string { + parsed, err := url.Parse(strings.TrimSpace(image)) + if err != nil { + return "" + } + if baseURL != nil { + parsed = baseURL.ResolveReference(parsed) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "" + } + return parsed.String() +} + +func truncateRunes(value string, max int) string { + value = strings.TrimSpace(value) + if runes := []rune(value); len(runes) > max { + return strings.TrimSpace(string(runes[:max])) + } + return value +} + +func (s *Server) getChatLinkPreview(ctx context.Context, messageID string) (*ChatLinkPreview, error) { + var preview ChatLinkPreview + + err := s.db.QueryRow( + ctx, + ` + SELECT url, title, description, image_url, site_name + FROM message_link_previews + WHERE message_id = $1 + `, + messageID, + ).Scan( + &preview.URL, + &preview.Title, + &preview.Description, + &preview.ImageURL, + &preview.SiteName, + ) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + + return &preview, nil +} + +type chatLinkPreviewRequest struct { + URL string `json:"url"` +} + +// handleChatLinkPreview liefert eine Vorschau für eine einzelne URL on demand, +// damit der Absender sie schon vor dem Senden über dem Eingabefeld sieht. +func (s *Server) handleChatLinkPreview(w http.ResponseWriter, r *http.Request) { + if _, ok := userFromContext(r.Context()); !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + var input chatLinkPreviewRequest + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Ungültige Anfrage") + return + } + + rawURL := firstHTTPURL(input.URL) + if rawURL == "" { + writeJSON(w, http.StatusOK, map[string]any{"preview": nil}) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second) + defer cancel() + + preview, err := fetchLinkPreview(ctx, rawURL) + if err != nil || + preview == nil || + (preview.Title == "" && preview.Description == "" && preview.ImageURL == "") { + writeJSON(w, http.StatusOK, map[string]any{"preview": nil}) + return + } + + writeJSON(w, http.StatusOK, map[string]any{"preview": preview}) +} + +// scheduleLinkPreview lädt – sofern die Nachricht eine URL enthält – die +// Vorschau asynchron, damit der Versand nicht durch externe Requests blockiert. +func (s *Server) scheduleLinkPreview(message ChatMessage) { + rawURL := firstHTTPURL(message.Body) + if rawURL == "" { + return + } + + go s.generateLinkPreview(message.ID, rawURL) +} + +func (s *Server) generateLinkPreview(messageID string, rawURL string) { + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) + defer cancel() + + preview, err := fetchLinkPreview(ctx, rawURL) + if err != nil || preview == nil { + return + } + if preview.Title == "" && preview.Description == "" && preview.ImageURL == "" { + return + } + + _, err = s.db.Exec( + ctx, + ` + INSERT INTO message_link_previews ( + message_id, url, title, description, image_url, site_name + ) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (message_id) DO UPDATE SET + url = EXCLUDED.url, + title = EXCLUDED.title, + description = EXCLUDED.description, + image_url = EXCLUDED.image_url, + site_name = EXCLUDED.site_name + `, + messageID, + preview.URL, + preview.Title, + preview.Description, + preview.ImageURL, + preview.SiteName, + ) + if err != nil { + return + } + + message, err := s.getChatMessage(ctx, messageID) + if err != nil { + return + } + + s.publishChatMessageUpdate(ctx, message) +} diff --git a/backend/chat_preferences.go b/backend/chat_preferences.go new file mode 100644 index 0000000..876830f --- /dev/null +++ b/backend/chat_preferences.go @@ -0,0 +1,65 @@ +package main + +import ( + "net/http" + "strings" +) + +type updateConversationMuteRequest struct { + Muted bool `json:"muted"` +} + +func (s *Server) handleUpdateConversationMute(w http.ResponseWriter, r *http.Request) { + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + conversationID := strings.TrimSpace(r.PathValue("id")) + if !s.canAccessConversation(r.Context(), conversationID, user.ID) { + writeError(w, http.StatusForbidden, "Kein Zugriff auf diesen Chat") + return + } + + var conversationType string + if err := s.db.QueryRow( + r.Context(), + `SELECT type FROM conversations WHERE id = $1`, + conversationID, + ).Scan(&conversationType); err != nil { + writeError(w, http.StatusNotFound, "Chat wurde nicht gefunden") + return + } + if conversationType == "direct" { + writeError(w, http.StatusBadRequest, "Einzelchats können nicht stummgeschaltet werden") + return + } + + var input updateConversationMuteRequest + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Ungültige Anfrage") + return + } + + _, err := s.db.Exec( + r.Context(), + ` + INSERT INTO conversation_members (conversation_id, user_id, muted) + VALUES ($1, $2, $3) + ON CONFLICT (conversation_id, user_id) + DO UPDATE SET muted = EXCLUDED.muted + `, + conversationID, + user.ID, + input.Muted, + ) + if err != nil { + writeError(w, http.StatusInternalServerError, "Stummschaltung konnte nicht gespeichert werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "muted": input.Muted, + }) +} diff --git a/backend/chat_search.go b/backend/chat_search.go new file mode 100644 index 0000000..78d17a7 --- /dev/null +++ b/backend/chat_search.go @@ -0,0 +1,109 @@ +package main + +import ( + "net/http" + "strings" + "time" +) + +type ChatSearchResult struct { + ConversationID string `json:"conversationId"` + MessageID string `json:"messageId"` + Body string `json:"body"` + SenderDisplayName string `json:"senderDisplayName"` + CreatedAt time.Time `json:"createdAt"` +} + +func (s *Server) handleSearchChatMessages(w http.ResponseWriter, r *http.Request) { + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + query := strings.TrimSpace(r.URL.Query().Get("q")) + if query == "" { + writeJSON(w, http.StatusOK, map[string]any{ + "results": []ChatSearchResult{}, + }) + return + } + + rows, err := s.db.Query( + r.Context(), + ` + SELECT DISTINCT ON (m.conversation_id) + m.conversation_id::TEXT, + m.id::TEXT, + m.body, + COALESCE( + NULLIF(u.display_name, ''), + NULLIF(u.username, ''), + u.email, + NULLIF(c.channel_source_name, ''), + c.name, + '' + ), + m.created_at + FROM messages m + JOIN conversations c ON c.id = m.conversation_id + LEFT JOIN users u ON u.id = m.sender_id + WHERE m.deleted_at IS NULL + AND strpos(lower(m.body), lower($2)) > 0 + AND ( + ( + c.type = 'team' + AND EXISTS ( + SELECT 1 + FROM user_teams ut + WHERE ut.team_id = c.team_id + AND ut.user_id = $1 + ) + ) + OR ( + c.type <> 'team' + AND EXISTS ( + SELECT 1 + FROM conversation_members cm + WHERE cm.conversation_id = c.id + AND cm.user_id = $1 + ) + ) + ) + ORDER BY m.conversation_id, m.created_at DESC, m.id DESC + LIMIT 50 + `, + user.ID, + query, + ) + if err != nil { + writeError(w, http.StatusInternalServerError, "Chatnachrichten konnten nicht durchsucht werden") + return + } + defer rows.Close() + + results := []ChatSearchResult{} + for rows.Next() { + var result ChatSearchResult + if err := rows.Scan( + &result.ConversationID, + &result.MessageID, + &result.Body, + &result.SenderDisplayName, + &result.CreatedAt, + ); err != nil { + writeError(w, http.StatusInternalServerError, "Suchergebnisse konnten nicht gelesen werden") + return + } + results = append(results, result) + } + + if err := rows.Err(); err != nil { + writeError(w, http.StatusInternalServerError, "Suchergebnisse konnten nicht vollständig geladen werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "results": results, + }) +} diff --git a/backend/chat_unread.go b/backend/chat_unread.go new file mode 100644 index 0000000..329b001 --- /dev/null +++ b/backend/chat_unread.go @@ -0,0 +1,121 @@ +package main + +import ( + "context" + "net/http" + "strings" +) + +func (s *Server) markChatConversationRead( + ctx context.Context, + conversationID string, + userID string, +) error { + _, err := s.db.Exec( + ctx, + ` + INSERT INTO conversation_members ( + conversation_id, + user_id, + last_read_at + ) + VALUES ($1, $2, now()) + ON CONFLICT (conversation_id, user_id) + DO UPDATE SET last_read_at = EXCLUDED.last_read_at + `, + conversationID, + userID, + ) + return err +} + +func (s *Server) getChatUnreadCount(ctx context.Context, userID string) (int, error) { + var unreadCount int + err := s.db.QueryRow( + ctx, + ` + SELECT COUNT(*)::INT + FROM messages m + JOIN conversations c ON c.id = m.conversation_id + LEFT JOIN conversation_members cm + ON cm.conversation_id = c.id + AND cm.user_id = $1 + WHERE m.deleted_at IS NULL + AND m.sender_id IS DISTINCT FROM $1::UUID + AND m.created_at > COALESCE(cm.last_read_at, '-infinity'::TIMESTAMPTZ) + AND ( + ( + c.type = 'team' + AND EXISTS ( + SELECT 1 + FROM user_teams ut + WHERE ut.team_id = c.team_id + AND ut.user_id = $1 + ) + ) + OR ( + c.type <> 'team' + AND cm.user_id IS NOT NULL + ) + ) + `, + userID, + ).Scan(&unreadCount) + return unreadCount, err +} + +func (s *Server) handleGetChatUnreadCount(w http.ResponseWriter, r *http.Request) { + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + if err := s.ensureTeamConversations(r.Context(), user.ID); err != nil { + writeError(w, http.StatusInternalServerError, "Team-Chats konnten nicht vorbereitet werden") + return + } + if err := s.ensureAllUserChannels(r.Context(), user.ID); err != nil { + writeError(w, http.StatusInternalServerError, "Channels konnten nicht vorbereitet werden") + return + } + + unreadCount, err := s.getChatUnreadCount(r.Context(), user.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, "Ungelesene Nachrichten konnten nicht geladen werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "unreadCount": unreadCount, + }) +} + +func (s *Server) handleMarkChatConversationRead(w http.ResponseWriter, r *http.Request) { + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + conversationID := strings.TrimSpace(r.PathValue("id")) + if !s.canAccessConversation(r.Context(), conversationID, user.ID) { + writeError(w, http.StatusForbidden, "Kein Zugriff auf diesen Chat") + return + } + + if err := s.markChatConversationRead(r.Context(), conversationID, user.ID); err != nil { + writeError(w, http.StatusInternalServerError, "Lesestatus konnte nicht gespeichert werden") + return + } + + unreadCount, err := s.getChatUnreadCount(r.Context(), user.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, "Ungelesene Nachrichten konnten nicht geladen werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "unreadCount": unreadCount, + }) +} diff --git a/backend/chat_websocket.go b/backend/chat_websocket.go new file mode 100644 index 0000000..4f4a1da --- /dev/null +++ b/backend/chat_websocket.go @@ -0,0 +1,464 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "sync" + "time" + + "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" +) + +type chatSocketCommand struct { + Type string `json:"type"` + ClientID string `json:"clientId"` + ConversationID string `json:"conversationId"` + Body string `json:"body"` + AttachmentIDs []string `json:"attachmentIds"` + ReplyToMessageID string `json:"replyToMessageId"` + ForwardedFromMessageID string `json:"forwardedFromMessageId"` + Location *ChatLocation `json:"location"` +} + +type chatSocketEvent struct { + Type string `json:"type"` + ClientID string `json:"clientId,omitempty"` + ConversationID string `json:"conversationId,omitempty"` + UserID string `json:"userId,omitempty"` + UserDisplayName string `json:"userDisplayName,omitempty"` + Typing bool `json:"typing,omitempty"` + Message *ChatMessage `json:"message,omitempty"` + Error string `json:"error,omitempty"` +} + +type chatSocketClient struct { + userID string + ch chan chatSocketEvent +} + +type chatSocketBroker struct { + mu sync.RWMutex + clients map[*chatSocketClient]struct{} +} + +var chatSockets = &chatSocketBroker{ + clients: map[*chatSocketClient]struct{}{}, +} + +func (b *chatSocketBroker) subscribe(userID string) *chatSocketClient { + client := &chatSocketClient{ + userID: userID, + ch: make(chan chatSocketEvent, 32), + } + + b.mu.Lock() + b.clients[client] = struct{}{} + b.mu.Unlock() + + return client +} + +func (b *chatSocketBroker) unsubscribe(client *chatSocketClient) { + b.mu.Lock() + delete(b.clients, client) + b.mu.Unlock() +} + +func (b *chatSocketBroker) publish(userID string, event chatSocketEvent) { + b.mu.RLock() + defer b.mu.RUnlock() + + for client := range b.clients { + if client.userID != userID { + continue + } + + select { + case client.ch <- event: + default: + } + } +} + +func (c *chatSocketClient) send(event chatSocketEvent) { + select { + case c.ch <- event: + default: + } +} + +func (s *Server) handleChatWebSocket(w http.ResponseWriter, r *http.Request) { + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + originPatterns := []string{} + if frontendOrigin := strings.TrimRight(s.frontendOrigin, "/"); frontendOrigin != "" { + originPatterns = append(originPatterns, frontendOrigin) + } + + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + OriginPatterns: originPatterns, + }) + if err != nil { + return + } + defer conn.CloseNow() + + conn.SetReadLimit(8 * 1024) + + client := chatSockets.subscribe(user.ID) + defer chatSockets.unsubscribe(client) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go s.readChatSocket(ctx, cancel, conn, client, user) + + client.send(chatSocketEvent{Type: "connected"}) + + pingTicker := time.NewTicker(30 * time.Second) + defer pingTicker.Stop() + + for { + select { + case <-ctx.Done(): + return + + case event, open := <-client.ch: + if !open { + return + } + + writeCtx, writeCancel := context.WithTimeout(ctx, 10*time.Second) + err := wsjson.Write(writeCtx, conn, event) + writeCancel() + if err != nil { + return + } + + case <-pingTicker.C: + pingCtx, pingCancel := context.WithTimeout(ctx, 10*time.Second) + err := conn.Ping(pingCtx) + pingCancel() + if err != nil { + return + } + } + } +} + +func (s *Server) readChatSocket( + ctx context.Context, + cancel context.CancelFunc, + conn *websocket.Conn, + client *chatSocketClient, + user User, +) { + defer cancel() + + for { + var command chatSocketCommand + if err := wsjson.Read(ctx, conn, &command); err != nil { + return + } + + switch command.Type { + case "typing.start", "typing.stop": + if !s.canAccessConversation(ctx, command.ConversationID, user.ID) || + !s.isConversationWritable(ctx, command.ConversationID) { + client.send(chatSocketEvent{ + Type: "error", + Error: "Dieser Chat ist schreibgeschützt", + }) + continue + } + + s.publishChatTyping( + ctx, + user, + command.ConversationID, + command.Type == "typing.start", + ) + continue + + case "message.send": + message, err := s.createChatMessage( + ctx, + user, + command.ConversationID, + createChatMessageInput{ + Body: command.Body, + AttachmentIDs: command.AttachmentIDs, + ReplyToMessageID: command.ReplyToMessageID, + ForwardedFromMessageID: command.ForwardedFromMessageID, + Location: command.Location, + }, + ) + if err != nil { + client.send(chatSocketEvent{ + Type: "error", + ClientID: command.ClientID, + Error: chatSocketErrorMessage(err), + }) + continue + } + + s.publishChatMessage(ctx, message, command.ClientID) + continue + + default: + client.send(chatSocketEvent{ + Type: "error", + ClientID: command.ClientID, + Error: "Unbekannte Chat-Aktion", + }) + } + } +} + +func chatSocketErrorMessage(err error) string { + switch { + case errors.Is(err, errChatAccessDenied): + return "Kein Zugriff auf diesen Chat" + case errors.Is(err, errChatMessageEmpty): + return "Nachricht darf nicht leer sein" + case errors.Is(err, errChatMessageTooLong): + return "Nachricht darf höchstens 4000 Zeichen enthalten" + case errors.Is(err, errChatLocationInvalid): + return "Ungültiger Standort" + case errors.Is(err, errChatForwardSame): + return "Nachrichten können nicht in denselben Chat weitergeleitet werden" + case errors.Is(err, errChatReadOnly): + return "Channels sind schreibgeschützt" + default: + return "Nachricht konnte nicht gesendet werden" + } +} + +func (s *Server) publishChatTyping( + ctx context.Context, + user User, + conversationID string, + typing bool, +) { + memberIDs, err := s.chatConversationMemberIDs(ctx, conversationID) + if err != nil { + return + } + + event := chatSocketEvent{ + Type: "typing.updated", + ConversationID: conversationID, + UserID: user.ID, + UserDisplayName: user.DisplayName, + Typing: typing, + } + if event.UserDisplayName == "" { + event.UserDisplayName = user.Username + } + if event.UserDisplayName == "" { + event.UserDisplayName = user.Email + } + + for _, memberID := range memberIDs { + if memberID != user.ID { + chatSockets.publish(memberID, event) + } + } +} + +func (s *Server) publishChatMessage( + ctx context.Context, + message ChatMessage, + clientID string, +) { + rows, err := s.db.Query( + ctx, + ` + SELECT cm.user_id::TEXT, cm.muted + FROM conversation_members cm + JOIN conversations c ON c.id = cm.conversation_id + WHERE cm.conversation_id = $1 + AND ( + c.type <> 'team' + OR EXISTS ( + SELECT 1 + FROM user_teams ut + WHERE ut.team_id = c.team_id + AND ut.user_id = cm.user_id + ) + ) + `, + message.ConversationID, + ) + if err != nil { + return + } + defer rows.Close() + + event := chatSocketEvent{ + Type: "message.created", + ClientID: clientID, + Message: &message, + } + + recipientIDs := []string{} + for rows.Next() { + var recipientID string + var muted bool + if err := rows.Scan(&recipientID, &muted); err != nil { + continue + } + + chatSockets.publish(recipientID, event) + if recipientID != message.Sender.ID && !muted { + recipientIDs = append(recipientIDs, recipientID) + } + } + + rows.Close() + + title, conversationType, err := s.chatNotificationTitle(ctx, message) + if err != nil { + return + } + + preview := strings.TrimSpace(message.Body) + if preview == "" && len(message.Attachments) > 0 { + preview = "Datei angehängt" + } + previewRunes := []rune(preview) + if len(previewRunes) > 160 { + preview = string(previewRunes[:160]) + "..." + } + + data, err := json.Marshal(map[string]any{ + "conversationId": message.ConversationID, + "messageId": message.ID, + "senderId": message.Sender.ID, + "senderAvatar": message.Sender.Avatar, + "senderName": chatUserDisplayName(message.Sender), + "conversationType": conversationType, + }) + if err != nil { + return + } + + for _, recipientID := range recipientIDs { + _ = s.createAndPublishNotification( + ctx, + recipientID, + "chat.message", + title, + preview, + "chat", + message.ConversationID, + data, + false, + ) + } + + s.scheduleLinkPreview(message) +} + +func (s *Server) chatConversationMemberIDs(ctx context.Context, conversationID string) ([]string, error) { + rows, err := s.db.Query( + ctx, + ` + SELECT cm.user_id::TEXT + FROM conversation_members cm + JOIN conversations c ON c.id = cm.conversation_id + WHERE cm.conversation_id = $1 + AND ( + c.type <> 'team' + OR EXISTS ( + SELECT 1 + FROM user_teams ut + WHERE ut.team_id = c.team_id + AND ut.user_id = cm.user_id + ) + ) + `, + conversationID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + memberIDs := []string{} + for rows.Next() { + var memberID string + if err := rows.Scan(&memberID); err != nil { + return nil, err + } + memberIDs = append(memberIDs, memberID) + } + + return memberIDs, rows.Err() +} + +func (s *Server) publishChatMessageUpdate(ctx context.Context, message ChatMessage) { + memberIDs, err := s.chatConversationMemberIDs(ctx, message.ConversationID) + if err != nil { + return + } + + event := chatSocketEvent{ + Type: "message.updated", + Message: &message, + } + + for _, memberID := range memberIDs { + chatSockets.publish(memberID, event) + } +} + +func chatUserDisplayName(user ChatUser) string { + if user.DisplayName != "" { + return user.DisplayName + } + if user.Username != "" { + return user.Username + } + return user.Email +} + +func (s *Server) chatNotificationTitle( + ctx context.Context, + message ChatMessage, +) (string, string, error) { + var conversationType string + var conversationName string + + err := s.db.QueryRow( + ctx, + ` + SELECT type, name + FROM conversations + WHERE id = $1 + `, + message.ConversationID, + ).Scan(&conversationType, &conversationName) + if err != nil { + return "", "", err + } + + senderName := chatUserDisplayName(message.Sender) + + if conversationType == "team" && conversationName != "" { + return senderName + " in " + conversationName, conversationType, nil + } + if conversationType == "channel" && conversationName != "" { + return "Neue Meldung in " + conversationName, conversationType, nil + } + + return "Neue Nachricht von " + senderName, conversationType, nil +} diff --git a/backend/feedback.go b/backend/feedback.go index d4a3b4c..83868b7 100644 --- a/backend/feedback.go +++ b/backend/feedback.go @@ -15,8 +15,17 @@ import ( type CreateFeedbackRequest struct { Message string `json:"message"` + // ClientLog ist das clientseitig erfasste Aktivitäts-/Fehlerprotokoll + // (Array). Client enthält Browser-/Geräte-Metadaten. + ClientLog json.RawMessage `json:"clientLog"` + Client json.RawMessage `json:"client"` } +const ( + maxFeedbackClientLogBytes = 512 * 1024 + maxFeedbackClientMetaBytes = 16 * 1024 +) + type FeedbackResponse struct { ID string `json:"id"` UserID string `json:"userId"` @@ -63,6 +72,16 @@ func (s *Server) handleCreateFeedback(w http.ResponseWriter, r *http.Request) { return } + var clientActivity any = json.RawMessage("[]") + if len(input.ClientLog) > 0 && len(input.ClientLog) <= maxFeedbackClientLogBytes { + clientActivity = input.ClientLog + } + + var clientMeta any + if len(input.Client) > 0 && len(input.Client) <= maxFeedbackClientMetaBytes { + clientMeta = input.Client + } + feedbackLog := map[string]any{ "user": buildUserLogSnapshot(user), "request": mergeLogFields( @@ -74,6 +93,8 @@ func (s *Server) handleCreateFeedback(w http.ResponseWriter, r *http.Request) { "server": map[string]any{ "createdAt": time.Now().UTC().Format(time.RFC3339Nano), }, + "client": clientMeta, + "activity": clientActivity, } logJSON, err := json.Marshal(feedbackLog) diff --git a/backend/go.mod b/backend/go.mod index e5f8878..355d6e1 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -5,13 +5,12 @@ module main go 1.26.2 require ( + github.com/coder/websocket v1.8.14 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/jackc/pgx/v5 v5.9.2 golang.org/x/crypto v0.51.0 ) -require github.com/coder/websocket v1.8.14 // indirect - require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect diff --git a/backend/main.go b/backend/main.go index ff8a620..577602a 100644 --- a/backend/main.go +++ b/backend/main.go @@ -37,6 +37,7 @@ func main() { } server := NewServer(db, jwtSecret, frontendOrigin) + server.startPresenceMonitor(ctx) log.Printf("Backend läuft auf http://localhost:%s", port) diff --git a/backend/notification_preferences.go b/backend/notification_preferences.go index e0fcfd1..48915b7 100644 --- a/backend/notification_preferences.go +++ b/backend/notification_preferences.go @@ -21,6 +21,8 @@ type notificationPreferencesResponse struct { DeviceUpdates bool `json:"deviceUpdates"` DeviceJournals bool `json:"deviceJournals"` SystemMessages bool `json:"systemMessages"` + ChatMessages bool `json:"chatMessages"` + DesktopEnabled bool `json:"desktopEnabled"` } func defaultNotificationPreferences() notificationPreferencesResponse { @@ -34,6 +36,8 @@ func defaultNotificationPreferences() notificationPreferencesResponse { DeviceUpdates: true, DeviceJournals: true, SystemMessages: true, + ChatMessages: true, + DesktopEnabled: false, } } @@ -102,9 +106,11 @@ func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *h operation_journals, device_updates, device_journals, - system_messages + system_messages, + chat_messages, + desktop_enabled ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT (user_id) DO UPDATE SET in_app_enabled = EXCLUDED.in_app_enabled, @@ -116,6 +122,8 @@ func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *h device_updates = EXCLUDED.device_updates, device_journals = EXCLUDED.device_journals, system_messages = EXCLUDED.system_messages, + chat_messages = EXCLUDED.chat_messages, + desktop_enabled = EXCLUDED.desktop_enabled, updated_at = now() `, user.ID, @@ -128,6 +136,8 @@ func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *h input.DeviceUpdates, input.DeviceJournals, input.SystemMessages, + input.ChatMessages, + input.DesktopEnabled, ) if err != nil { writeError(w, http.StatusInternalServerError, "Benachrichtigungseinstellungen konnten nicht gespeichert werden") @@ -160,7 +170,9 @@ func (s *Server) getNotificationPreferences(ctx context.Context, userID string) operation_journals, device_updates, device_journals, - system_messages + system_messages, + chat_messages, + desktop_enabled FROM notification_preferences WHERE user_id = $1 LIMIT 1 @@ -176,6 +188,8 @@ func (s *Server) getNotificationPreferences(ctx context.Context, userID string) &settings.DeviceUpdates, &settings.DeviceJournals, &settings.SystemMessages, + &settings.ChatMessages, + &settings.DesktopEnabled, ) settings.SoundName = normalizeNotificationSoundName(settings.SoundName) diff --git a/backend/notifications.go b/backend/notifications.go index 7d52c01..416bb28 100644 --- a/backend/notifications.go +++ b/backend/notifications.go @@ -24,6 +24,8 @@ type Notification struct { Data json.RawMessage `json:"data"` Silent bool `json:"silent"` SoundName string `json:"soundName,omitempty"` + Desktop bool `json:"desktop,omitempty"` + InApp bool `json:"inAppEnabled"` ReadAt *time.Time `json:"readAt"` CreatedAt time.Time `json:"createdAt"` } @@ -95,11 +97,13 @@ func shouldDeliverNotification( notificationType string, entityType string, ) bool { - if !preferences.InAppEnabled { - return false - } - switch { + case strings.HasPrefix(notificationType, "chat.") || entityType == "chat": + return preferences.ChatMessages + + case !preferences.InAppEnabled: + return false + case notificationType == "operation.journal": return preferences.OperationJournals @@ -150,6 +154,8 @@ func (s *Server) createAndPublishNotification( return nil } + inAppVisible := silent || (preferences.InAppEnabled && entityType != "chat") + var notification Notification var rawData []byte @@ -164,7 +170,8 @@ func (s *Server) createAndPublishNotification( entity_type, entity_id, data, - silent + silent, + in_app_visible ) VALUES ( $1, @@ -174,7 +181,8 @@ func (s *Server) createAndPublishNotification( $5, NULLIF($6, '')::UUID, $7::JSONB, - $8 + $8, + $9 ) RETURNING id::TEXT, @@ -196,6 +204,7 @@ func (s *Server) createAndPublishNotification( entityID, string(dataJSON), silent, + inAppVisible, ).Scan( ¬ification.ID, ¬ification.UserID, @@ -216,6 +225,11 @@ func (s *Server) createAndPublishNotification( if !notification.Silent { notification.SoundName = notificationSoundName(preferences) + notification.Desktop = preferences.DesktopEnabled + notification.InApp = preferences.InAppEnabled + if notification.EntityType == "chat" { + notification.InApp = preferences.ChatMessages + } } notificationsBroker.publish(notification) @@ -306,6 +320,8 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request) FROM notifications WHERE user_id = $1 AND silent = false + AND in_app_visible = true + AND entity_type <> 'chat' ORDER BY created_at DESC LIMIT 50 `, diff --git a/backend/operations.go b/backend/operations.go index e84dfe5..47c5fa2 100644 --- a/backend/operations.go +++ b/backend/operations.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "math" "net/http" "strings" "time" @@ -16,16 +17,22 @@ import ( ) type Operation struct { - ID string `json:"id"` - OperationNumber string `json:"operationNumber"` - OperationName string `json:"operationName"` - Offense string `json:"offense"` - CaseWorker string `json:"caseWorker"` - TargetObject string `json:"targetObject"` - KwAddress string `json:"kwAddress"` - OperationLeader string `json:"operationLeader"` - OperationTeam string `json:"operationTeam"` - Legend string `json:"legend"` + ID string `json:"id"` + OperationNumber string `json:"operationNumber"` + OperationName string `json:"operationName"` + Offense string `json:"offense"` + CaseWorker string `json:"caseWorker"` + TargetObject string `json:"targetObject"` + KwAddress string `json:"kwAddress"` + KwLatitude *float64 `json:"kwLatitude"` + KwLongitude *float64 `json:"kwLongitude"` + TargetLatitude *float64 `json:"targetLatitude"` + TargetLongitude *float64 `json:"targetLongitude"` + ViewConeFOV float64 `json:"viewConeFov"` + ViewConeLength float64 `json:"viewConeLength"` + OperationLeader string `json:"operationLeader"` + OperationTeam string `json:"operationTeam"` + Legend string `json:"legend"` Camera string `json:"camera"` Router string `json:"router"` Technology string `json:"technology"` @@ -47,23 +54,29 @@ type UpdateOperationJournalEntryRequest struct { } type CreateOperationRequest struct { - OperationNumber string `json:"operationNumber"` - OperationName string `json:"operationName"` - Offense string `json:"offense"` - CaseWorker string `json:"caseWorker"` - TargetObject string `json:"targetObject"` - KwAddress string `json:"kwAddress"` - OperationLeader string `json:"operationLeader"` - OperationTeam string `json:"operationTeam"` - Legend string `json:"legend"` - Camera string `json:"camera"` - Router string `json:"router"` - Technology string `json:"technology"` - Switchbox string `json:"switchbox"` - HardDrive string `json:"hardDrive"` - Laptop string `json:"laptop"` - Remark string `json:"remark"` - MilestoneStorage string `json:"milestoneStorage"` + OperationNumber string `json:"operationNumber"` + OperationName string `json:"operationName"` + Offense string `json:"offense"` + CaseWorker string `json:"caseWorker"` + TargetObject string `json:"targetObject"` + KwAddress string `json:"kwAddress"` + KwLatitude *float64 `json:"kwLatitude"` + KwLongitude *float64 `json:"kwLongitude"` + TargetLatitude *float64 `json:"targetLatitude"` + TargetLongitude *float64 `json:"targetLongitude"` + ViewConeFOV float64 `json:"viewConeFov"` + ViewConeLength float64 `json:"viewConeLength"` + OperationLeader string `json:"operationLeader"` + OperationTeam string `json:"operationTeam"` + Legend string `json:"legend"` + Camera string `json:"camera"` + Router string `json:"router"` + Technology string `json:"technology"` + Switchbox string `json:"switchbox"` + HardDrive string `json:"hardDrive"` + Laptop string `json:"laptop"` + Remark string `json:"remark"` + MilestoneStorage string `json:"milestoneStorage"` } type UpdateOperationRequest = CreateOperationRequest @@ -119,6 +132,12 @@ func (s *Server) handleListOperations(w http.ResponseWriter, r *http.Request) { case_worker, target_object, kw_address, + kw_latitude, + kw_longitude, + target_latitude, + target_longitude, + view_cone_fov, + view_cone_length, operation_leader, operation_team, legend, @@ -156,6 +175,12 @@ func (s *Server) handleListOperations(w http.ResponseWriter, r *http.Request) { &operation.CaseWorker, &operation.TargetObject, &operation.KwAddress, + &operation.KwLatitude, + &operation.KwLongitude, + &operation.TargetLatitude, + &operation.TargetLongitude, + &operation.ViewConeFOV, + &operation.ViewConeLength, &operation.OperationLeader, &operation.OperationTeam, &operation.Legend, @@ -201,6 +226,10 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "Einsatznummer ist erforderlich") return } + if err := validateOperationGeometry(input); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } user, ok := userFromContext(r.Context()) if !ok { @@ -227,6 +256,12 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) { case_worker, target_object, kw_address, + kw_latitude, + kw_longitude, + target_latitude, + target_longitude, + view_cone_fov, + view_cone_length, operation_leader, operation_team, legend, @@ -239,7 +274,7 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) { remark, milestone_storage ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23) RETURNING id, operation_number, @@ -248,6 +283,12 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) { case_worker, target_object, kw_address, + kw_latitude, + kw_longitude, + target_latitude, + target_longitude, + view_cone_fov, + view_cone_length, operation_leader, operation_team, legend, @@ -268,6 +309,12 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) { input.CaseWorker, input.TargetObject, input.KwAddress, + input.KwLatitude, + input.KwLongitude, + input.TargetLatitude, + input.TargetLongitude, + input.ViewConeFOV, + input.ViewConeLength, input.OperationLeader, input.OperationTeam, input.Legend, @@ -287,6 +334,12 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) { &operation.CaseWorker, &operation.TargetObject, &operation.KwAddress, + &operation.KwLatitude, + &operation.KwLongitude, + &operation.TargetLatitude, + &operation.TargetLongitude, + &operation.ViewConeFOV, + &operation.ViewConeLength, &operation.OperationLeader, &operation.OperationTeam, &operation.Legend, @@ -380,6 +433,10 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "Einsatznummer ist erforderlich") return } + if err := validateOperationGeometry(input); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } user, ok := userFromContext(r.Context()) if !ok { @@ -418,17 +475,23 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) { case_worker = $5, target_object = $6, kw_address = $7, - operation_leader = $8, - operation_team = $9, - legend = $10, - camera = $11, - router = $12, - technology = $13, - switchbox = $14, - hard_drive = $15, - laptop = $16, - remark = $17, - milestone_storage = $18, + kw_latitude = $8, + kw_longitude = $9, + target_latitude = $10, + target_longitude = $11, + view_cone_fov = $12, + view_cone_length = $13, + operation_leader = $14, + operation_team = $15, + legend = $16, + camera = $17, + router = $18, + technology = $19, + switchbox = $20, + hard_drive = $21, + laptop = $22, + remark = $23, + milestone_storage = $24, updated_at = now() WHERE id = $1 RETURNING @@ -439,6 +502,12 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) { case_worker, target_object, kw_address, + kw_latitude, + kw_longitude, + target_latitude, + target_longitude, + view_cone_fov, + view_cone_length, operation_leader, operation_team, legend, @@ -460,6 +529,12 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) { input.CaseWorker, input.TargetObject, input.KwAddress, + input.KwLatitude, + input.KwLongitude, + input.TargetLatitude, + input.TargetLongitude, + input.ViewConeFOV, + input.ViewConeLength, input.OperationLeader, input.OperationTeam, input.Legend, @@ -479,6 +554,12 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) { &operation.CaseWorker, &operation.TargetObject, &operation.KwAddress, + &operation.KwLatitude, + &operation.KwLongitude, + &operation.TargetLatitude, + &operation.TargetLongitude, + &operation.ViewConeFOV, + &operation.ViewConeLength, &operation.OperationLeader, &operation.OperationTeam, &operation.Legend, @@ -644,6 +725,12 @@ func getOperationForHistory(ctx context.Context, tx pgx.Tx, operationID string) case_worker, target_object, kw_address, + kw_latitude, + kw_longitude, + target_latitude, + target_longitude, + view_cone_fov, + view_cone_length, operation_leader, operation_team, legend, @@ -670,6 +757,12 @@ func getOperationForHistory(ctx context.Context, tx pgx.Tx, operationID string) &operation.CaseWorker, &operation.TargetObject, &operation.KwAddress, + &operation.KwLatitude, + &operation.KwLongitude, + &operation.TargetLatitude, + &operation.TargetLongitude, + &operation.ViewConeFOV, + &operation.ViewConeLength, &operation.OperationLeader, &operation.OperationTeam, &operation.Legend, @@ -730,6 +823,12 @@ func buildOperationCreateHistoryChanges(input CreateOperationRequest) []Operatio changes = appendOperationHistoryChange(changes, "caseWorker", "Sachbearbeitung", "", input.CaseWorker) changes = appendOperationHistoryChange(changes, "targetObject", "Zielobjekt", "", input.TargetObject) changes = appendOperationHistoryChange(changes, "kwAddress", "KW", "", input.KwAddress) + changes = appendOperationHistoryChange(changes, "kwPosition", "KW-Position", "", formatOperationCoordinates(input.KwLatitude, input.KwLongitude)) + changes = appendOperationHistoryChange(changes, "targetPosition", "ZO-Position", "", formatOperationCoordinates(input.TargetLatitude, input.TargetLongitude)) + if input.KwLatitude != nil && input.TargetLatitude != nil { + changes = appendOperationHistoryChange(changes, "viewConeFov", "Sichtfeld", "", fmt.Sprintf("%.0f°", input.ViewConeFOV)) + changes = appendOperationHistoryChange(changes, "viewConeLength", "Sichtweite", "", formatOperationDistance(input.ViewConeLength)) + } changes = appendOperationHistoryChange(changes, "operationLeader", "Einsatzleiter", "", input.OperationLeader) changes = appendOperationHistoryChange(changes, "operationTeam", "Einsatzteam", "", input.OperationTeam) changes = appendOperationHistoryChange(changes, "legend", "Legende", "", input.Legend) @@ -754,6 +853,10 @@ func buildOperationUpdateHistoryChanges(oldOperation Operation, input UpdateOper changes = appendOperationHistoryChange(changes, "caseWorker", "Sachbearbeitung", oldOperation.CaseWorker, input.CaseWorker) changes = appendOperationHistoryChange(changes, "targetObject", "Zielobjekt", oldOperation.TargetObject, input.TargetObject) changes = appendOperationHistoryChange(changes, "kwAddress", "KW", oldOperation.KwAddress, input.KwAddress) + changes = appendOperationHistoryChange(changes, "kwPosition", "KW-Position", formatOperationCoordinates(oldOperation.KwLatitude, oldOperation.KwLongitude), formatOperationCoordinates(input.KwLatitude, input.KwLongitude)) + changes = appendOperationHistoryChange(changes, "targetPosition", "ZO-Position", formatOperationCoordinates(oldOperation.TargetLatitude, oldOperation.TargetLongitude), formatOperationCoordinates(input.TargetLatitude, input.TargetLongitude)) + changes = appendOperationHistoryChange(changes, "viewConeFov", "Sichtfeld", fmt.Sprintf("%.0f°", oldOperation.ViewConeFOV), fmt.Sprintf("%.0f°", input.ViewConeFOV)) + changes = appendOperationHistoryChange(changes, "viewConeLength", "Sichtweite", formatOperationDistance(oldOperation.ViewConeLength), formatOperationDistance(input.ViewConeLength)) changes = appendOperationHistoryChange(changes, "operationLeader", "Einsatzleiter", oldOperation.OperationLeader, input.OperationLeader) changes = appendOperationHistoryChange(changes, "operationTeam", "Einsatzteam", oldOperation.OperationTeam, input.OperationTeam) changes = appendOperationHistoryChange(changes, "legend", "Legende", oldOperation.Legend, input.Legend) @@ -809,6 +912,56 @@ func normalizeOperationInput(input *CreateOperationRequest) { input.Laptop = strings.TrimSpace(input.Laptop) input.Remark = strings.TrimSpace(input.Remark) input.MilestoneStorage = strings.TrimSpace(input.MilestoneStorage) + if input.ViewConeFOV == 0 { + input.ViewConeFOV = 36 + } +} + +func validateOperationGeometry(input CreateOperationRequest) error { + if err := validateOperationCoordinatePair("KW", input.KwLatitude, input.KwLongitude); err != nil { + return err + } + if err := validateOperationCoordinatePair("ZO", input.TargetLatitude, input.TargetLongitude); err != nil { + return err + } + if input.ViewConeFOV < 1 || input.ViewConeFOV > 180 { + return errors.New("Das Sichtfeld muss zwischen 1 und 180 Grad liegen") + } + if math.IsNaN(input.ViewConeLength) || math.IsInf(input.ViewConeLength, 0) || + input.ViewConeLength < 0 || input.ViewConeLength > 100000 { + return errors.New("Die Sichtweite muss zwischen 0 und 100000 Metern liegen") + } + return nil +} + +func validateOperationCoordinatePair(label string, latitude, longitude *float64) error { + if latitude == nil && longitude == nil { + return nil + } + if latitude == nil || longitude == nil { + return fmt.Errorf("%s-Koordinaten sind unvollständig", label) + } + if math.IsNaN(*latitude) || math.IsInf(*latitude, 0) || + math.IsNaN(*longitude) || math.IsInf(*longitude, 0) || + *latitude < -90 || *latitude > 90 || + *longitude < -180 || *longitude > 180 { + return fmt.Errorf("%s-Koordinaten sind ungültig", label) + } + return nil +} + +func formatOperationCoordinates(latitude, longitude *float64) string { + if latitude == nil || longitude == nil { + return "" + } + return fmt.Sprintf("%.6f, %.6f", *latitude, *longitude) +} + +func formatOperationDistance(distance float64) string { + if distance <= 0 { + return "Automatisch bis ZO" + } + return fmt.Sprintf("%.0f m", distance) } func (s *Server) handleCreateOperationJournalEntry(w http.ResponseWriter, r *http.Request) { diff --git a/backend/presence.go b/backend/presence.go new file mode 100644 index 0000000..93410e5 --- /dev/null +++ b/backend/presence.go @@ -0,0 +1,309 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "time" +) + +const presenceStatusSQL = ` + CASE + WHEN presence_mode = 'offline' + OR last_seen_at IS NULL + OR last_seen_at < now() - INTERVAL '2 minutes' + THEN 'offline' + WHEN presence_mode = 'away' + OR last_active_at IS NULL + OR last_active_at < now() - make_interval(mins => away_after_minutes) + THEN 'away' + ELSE 'online' + END +` + +type presenceHeartbeatRequest struct { + Active bool `json:"active"` +} + +type presenceSettingsRequest struct { + Mode string `json:"mode"` + AwayAfterMinutes int `json:"awayAfterMinutes"` + ShowLastSeen bool `json:"showLastSeen"` +} + +type presenceStatusRequest struct { + Mode string `json:"mode"` +} + +func normalizePresenceMode(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "away": + return "away" + case "offline": + return "offline" + default: + return "online" + } +} + +func (s *Server) startPresenceMonitor(ctx context.Context) { + s.refreshPresenceStatuses(ctx, false) + + go func() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.refreshPresenceStatuses(ctx, true) + } + } + }() +} + +func (s *Server) refreshPresenceStatuses(ctx context.Context, publish bool) { + rows, err := s.db.Query( + ctx, + ` + SELECT id::TEXT, `+presenceStatusSQL+` + FROM users + `, + ) + if err != nil { + return + } + defer rows.Close() + + currentStatuses := map[string]string{} + changedUserIDs := []string{} + + for rows.Next() { + var userID string + var status string + if err := rows.Scan(&userID, &status); err != nil { + return + } + + currentStatuses[userID] = status + + s.presenceMu.Lock() + previousStatus, known := s.presenceStatus[userID] + s.presenceMu.Unlock() + + if publish && known && previousStatus != status { + changedUserIDs = append(changedUserIDs, userID) + } + } + + if rows.Err() != nil { + return + } + + s.presenceMu.Lock() + s.presenceStatus = currentStatuses + s.presenceMu.Unlock() + + for _, userID := range changedUserIDs { + user, err := s.getUserByID(ctx, userID) + if err == nil { + s.publishUserProfileEvent("user.presence", user) + } + } +} + +func (s *Server) publishPresenceIfChanged(user User) { + s.presenceMu.Lock() + previousStatus, known := s.presenceStatus[user.ID] + s.presenceStatus[user.ID] = user.PresenceStatus + s.presenceMu.Unlock() + + if !known || previousStatus != user.PresenceStatus { + s.publishUserProfileEvent("user.presence", user) + } +} + +func (s *Server) handlePresence(w http.ResponseWriter, r *http.Request) { + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + var input presenceHeartbeatRequest + if r.Body != nil { + _ = json.NewDecoder(r.Body).Decode(&input) + } + + _, err := s.db.Exec( + r.Context(), + ` + UPDATE users + SET + last_seen_at = now(), + last_active_at = CASE + WHEN $2 AND presence_mode = 'online' THEN now() + ELSE last_active_at + END + WHERE id = $1 + `, + user.ID, + input.Active, + ) + if err != nil { + writeError(w, http.StatusInternalServerError, "Präsenz konnte nicht aktualisiert werden") + return + } + + updatedUser, err := s.getUserByID(r.Context(), user.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, "Präsenz konnte nicht geladen werden") + return + } + + s.publishPresenceIfChanged(updatedUser) + + writeJSON(w, http.StatusOK, map[string]any{ + "presenceStatus": updatedUser.PresenceStatus, + "lastSeenAt": updatedUser.LastSeenAt, + }) +} + +func (s *Server) handleGetPresenceSettings(w http.ResponseWriter, r *http.Request) { + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "settings": map[string]any{ + "mode": user.PresenceMode, + "awayAfterMinutes": user.AwayAfterMinutes, + "currentStatus": user.PresenceStatus, + "showLastSeen": user.ShowLastSeen, + }, + }) +} + +func (s *Server) handleUpdatePresenceSettings(w http.ResponseWriter, r *http.Request) { + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + var input presenceSettingsRequest + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Ungültige Anfrage") + return + } + + mode := user.PresenceMode + if strings.TrimSpace(input.Mode) != "" { + mode = normalizePresenceMode(input.Mode) + } + if input.AwayAfterMinutes < 1 || input.AwayAfterMinutes > 120 { + writeError(w, http.StatusBadRequest, "Automatische Abwesenheit muss zwischen 1 und 120 Minuten liegen") + return + } + + _, err := s.db.Exec( + r.Context(), + ` + UPDATE users + SET + presence_mode_updated_at = CASE + WHEN presence_mode IS DISTINCT FROM $2 THEN now() + ELSE presence_mode_updated_at + END, + presence_mode = $2, + away_after_minutes = $3, + show_last_seen = $4, + last_active_at = CASE WHEN $2 = 'online' THEN now() ELSE last_active_at END, + updated_at = now() + WHERE id = $1 + `, + user.ID, + mode, + input.AwayAfterMinutes, + input.ShowLastSeen, + ) + if err != nil { + writeError(w, http.StatusInternalServerError, "Präsenzeinstellungen konnten nicht gespeichert werden") + return + } + + updatedUser, err := s.getUserByID(r.Context(), user.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, "Präsenzeinstellungen konnten nicht geladen werden") + return + } + + s.publishPresenceIfChanged(updatedUser) + + writeJSON(w, http.StatusOK, map[string]any{ + "settings": map[string]any{ + "mode": updatedUser.PresenceMode, + "awayAfterMinutes": updatedUser.AwayAfterMinutes, + "currentStatus": updatedUser.PresenceStatus, + "showLastSeen": updatedUser.ShowLastSeen, + }, + "user": updatedUser, + }) +} + +func (s *Server) handleUpdatePresenceStatus(w http.ResponseWriter, r *http.Request) { + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + var input presenceStatusRequest + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Ungültige Anfrage") + return + } + + mode := strings.ToLower(strings.TrimSpace(input.Mode)) + if mode != "online" && mode != "away" && mode != "offline" { + writeError(w, http.StatusBadRequest, "Ungültiger Status") + return + } + + _, err := s.db.Exec( + r.Context(), + ` + UPDATE users + SET + presence_mode_updated_at = CASE + WHEN presence_mode IS DISTINCT FROM $2 THEN now() + ELSE presence_mode_updated_at + END, + presence_mode = $2, + last_active_at = CASE WHEN $2 = 'online' THEN now() ELSE last_active_at END, + last_seen_at = now(), + updated_at = now() + WHERE id = $1 + `, + user.ID, + mode, + ) + if err != nil { + writeError(w, http.StatusInternalServerError, "Status konnte nicht gespeichert werden") + return + } + + updatedUser, err := s.getUserByID(r.Context(), user.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, "Status konnte nicht geladen werden") + return + } + + s.publishPresenceIfChanged(updatedUser) + writeJSON(w, http.StatusOK, map[string]any{"user": updatedUser}) +} diff --git a/backend/routes.go b/backend/routes.go index c247b4d..2c39dd4 100644 --- a/backend/routes.go +++ b/backend/routes.go @@ -10,6 +10,7 @@ func (s *Server) Routes() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /health", s.handleHealth) + mux.HandleFunc("POST /webhooks/channels/{slug}", s.handleChannelWebhook) mux.HandleFunc("POST /auth/register", s.handleRegister) mux.HandleFunc("POST /auth/login", s.handleLogin) @@ -81,6 +82,24 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("POST /notifications/read-all", s.requireAuth(s.handleMarkAllNotificationsRead)) mux.HandleFunc("GET /events", s.requireAuth(s.handleNotificationEvents)) + mux.HandleFunc("POST /presence", s.requireAuth(s.handlePresence)) + mux.HandleFunc("GET /settings/presence", s.requireAuth(s.handleGetPresenceSettings)) + mux.HandleFunc("PUT /settings/presence", s.requireAuth(s.handleUpdatePresenceSettings)) + mux.HandleFunc("PUT /settings/presence/status", s.requireAuth(s.handleUpdatePresenceStatus)) + + mux.HandleFunc("GET /chat/conversations", s.requireAuth(s.handleListChatConversations)) + mux.HandleFunc("GET /chat/search", s.requireAuth(s.handleSearchChatMessages)) + mux.HandleFunc("GET /chat/unread", s.requireAuth(s.handleGetChatUnreadCount)) + mux.HandleFunc("POST /chat/direct", s.requireAuth(s.handleCreateDirectConversation)) + mux.HandleFunc("GET /chat/users", s.requireAuth(s.handleListChatUsers)) + mux.HandleFunc("GET /chat/ws", s.requireAuth(s.handleChatWebSocket)) + mux.HandleFunc("POST /chat/attachments", s.requireAuth(s.handleUploadChatAttachment)) + mux.HandleFunc("GET /chat/attachments/{id}", s.requireAuth(s.handleDownloadChatAttachment)) + mux.HandleFunc("POST /chat/link-preview", s.requireAuth(s.handleChatLinkPreview)) + mux.HandleFunc("GET /chat/conversations/{id}/messages", s.requireAuth(s.handleListChatMessages)) + mux.HandleFunc("POST /chat/conversations/{id}/messages", s.requireAuth(s.handleCreateChatMessage)) + mux.HandleFunc("POST /chat/conversations/{id}/read", s.requireAuth(s.handleMarkChatConversationRead)) + mux.HandleFunc("PUT /chat/conversations/{id}/mute", s.requireAuth(s.handleUpdateConversationMute)) mux.HandleFunc("GET /dashboard/operation-changes", s.requireAuth(s.requireRight("operations:read", s.handleDashboardOperationChanges))) @@ -107,6 +126,10 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("GET /admin/feedback", s.requireAuth(s.requireRight("administration:read", s.handleListFeedback))) mux.HandleFunc("DELETE /admin/feedback/{id}", s.requireAuth(s.requireRight("administration:write", s.handleDeleteFeedback))) + mux.HandleFunc("GET /admin/channels", s.requireAuth(s.requireRight("administration:read", s.handleAdminListChannels))) + mux.HandleFunc("POST /admin/channels", s.requireAuth(s.requireRight("administration:write", s.handleAdminCreateChannel))) + mux.HandleFunc("PUT /admin/channels/{id}", s.requireAuth(s.requireRight("administration:write", s.handleAdminUpdateChannel))) + mux.HandleFunc("POST /admin/channels/{id}/token", s.requireAuth(s.requireRight("administration:write", s.handleAdminRotateChannelToken))) /* milestone settings */ diff --git a/backend/server.go b/backend/server.go index fb5ad53..dca70f8 100644 --- a/backend/server.go +++ b/backend/server.go @@ -8,6 +8,7 @@ import ( "errors" "net/http" "strings" + "sync" "time" "github.com/golang-jwt/jwt/v5" @@ -27,19 +28,27 @@ type Server struct { db *pgxpool.Pool jwtSecret []byte frontendOrigin string + presenceMu sync.Mutex + presenceStatus map[string]string } type User 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"` - Rights []string `json:"rights"` - Teams []Team `json:"teams"` - TokenVersion int `json:"-"` + 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"` + Rights []string `json:"rights"` + Teams []Team `json:"teams"` + Online bool `json:"onlineStatus"` + PresenceMode string `json:"presenceMode"` + PresenceStatus string `json:"presenceStatus"` + AwayAfterMinutes int `json:"awayAfterMinutes"` + ShowLastSeen bool `json:"showLastSeen"` + LastSeenAt *time.Time `json:"lastSeenAt"` + TokenVersion int `json:"-"` } type Team struct { @@ -87,6 +96,7 @@ func NewServer(db *pgxpool.Pool, jwtSecret string, frontendOrigin string) *Serve db: db, jwtSecret: []byte(jwtSecret), frontendOrigin: frontendOrigin, + presenceStatus: map[string]string{}, } } @@ -196,6 +206,14 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { return } + user.Online = true + user.PresenceMode = "online" + user.PresenceStatus = "online" + user.AwayAfterMinutes = 3 + user.ShowLastSeen = false + now := time.Now() + user.LastSeenAt = &now + if err := s.setSessionCookie(w, user, sessionID); err != nil { writeError(w, http.StatusInternalServerError, "Could not create session") return @@ -249,6 +267,11 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { return } + user.PresenceStatus = user.PresenceMode + user.Online = user.PresenceStatus == "online" + now := time.Now() + user.LastSeenAt = &now + if err := s.setSessionCookie(w, user, sessionID); err != nil { writeError(w, http.StatusInternalServerError, "Could not create session") return @@ -264,10 +287,22 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { _, _ = s.db.Exec( r.Context(), ` - UPDATE user_sessions - SET revoked_at = now() - WHERE id = $1 - AND revoked_at IS NULL + WITH revoked_session AS ( + UPDATE user_sessions + SET revoked_at = now() + WHERE id = $1 + AND revoked_at IS NULL + RETURNING user_id + ) + UPDATE users + SET last_seen_at = ( + SELECT MAX(us.last_seen_at) + FROM user_sessions us + WHERE us.user_id = revoked_session.user_id + AND us.revoked_at IS NULL + ) + FROM revoked_session + WHERE users.id = revoked_session.user_id `, sessionID, ) @@ -381,6 +416,11 @@ func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc { } _ = s.touchUserSession(r.Context(), user.ID, claims.SessionID) + user, err = s.getUserByID(r.Context(), user.ID) + if err != nil { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } ctx := context.WithValue(r.Context(), userContextKey, user) ctx = context.WithValue(ctx, sessionContextKey, claims.SessionID) @@ -408,6 +448,11 @@ func (s *Server) getUserByIdentifier(ctx context.Context, identifier string) (Us COALESCE(unit, ''), COALESCE(user_group, ''), COALESCE(rights, '{}'::TEXT[]), + COALESCE(presence_mode, 'online'), + away_after_minutes, + show_last_seen, + `+presenceStatusSQL+`, + last_seen_at, token_version FROM users WHERE lower(email) = $1 OR lower(username) = $1 @@ -424,8 +469,14 @@ func (s *Server) getUserByIdentifier(ctx context.Context, identifier string) (Us &user.Unit, &user.Group, &user.Rights, + &user.PresenceMode, + &user.AwayAfterMinutes, + &user.ShowLastSeen, + &user.PresenceStatus, + &user.LastSeenAt, &user.TokenVersion, ) + user.Online = user.PresenceStatus == "online" if err != nil { return user, passwordHash, err @@ -456,6 +507,11 @@ func (s *Server) getUserByID(ctx context.Context, userID string) (User, error) { COALESCE(unit, ''), COALESCE(user_group, ''), COALESCE(rights, '{}'::TEXT[]), + COALESCE(presence_mode, 'online'), + away_after_minutes, + show_last_seen, + `+presenceStatusSQL+`, + last_seen_at, token_version FROM users WHERE id = $1 @@ -471,8 +527,14 @@ func (s *Server) getUserByID(ctx context.Context, userID string) (User, error) { &user.Unit, &user.Group, &user.Rights, + &user.PresenceMode, + &user.AwayAfterMinutes, + &user.ShowLastSeen, + &user.PresenceStatus, + &user.LastSeenAt, &user.TokenVersion, ) + user.Online = user.PresenceStatus == "online" if err != nil { return user, err diff --git a/backend/setup/main.go b/backend/setup/main.go index 088ded7..1aad2f3 100644 --- a/backend/setup/main.go +++ b/backend/setup/main.go @@ -229,6 +229,13 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { token_version INTEGER NOT NULL DEFAULT 1, last_seen_at TIMESTAMPTZ, + last_active_at TIMESTAMPTZ, + presence_mode TEXT NOT NULL DEFAULT 'online' + CHECK (presence_mode IN ('online', 'away', 'offline')), + presence_mode_updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + away_after_minutes INTEGER NOT NULL DEFAULT 3 + CHECK (away_after_minutes BETWEEN 1 AND 120), + show_last_seen BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() @@ -239,6 +246,58 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { // aktualisiert. "Online" wird daraus abgeleitet (z. B. Aktivität innerhalb // der letzten Minuten). `ALTER TABLE IF EXISTS users ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ`, + `ALTER TABLE IF EXISTS users ADD COLUMN IF NOT EXISTS last_active_at TIMESTAMPTZ`, + `ALTER TABLE IF EXISTS users ADD COLUMN IF NOT EXISTS presence_mode TEXT NOT NULL DEFAULT 'online'`, + `ALTER TABLE IF EXISTS users ADD COLUMN IF NOT EXISTS presence_mode_updated_at TIMESTAMPTZ NOT NULL DEFAULT now()`, + `ALTER TABLE IF EXISTS users ADD COLUMN IF NOT EXISTS away_after_minutes INTEGER NOT NULL DEFAULT 3`, + ` + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'users' + AND column_name = 'away_after_minutes' + AND column_default LIKE '5%' + ) THEN + UPDATE users + SET away_after_minutes = 3 + WHERE away_after_minutes = 5; + + ALTER TABLE users + ALTER COLUMN away_after_minutes SET DEFAULT 3; + END IF; + END + $$ + `, + `ALTER TABLE IF EXISTS users ADD COLUMN IF NOT EXISTS show_last_seen BOOLEAN NOT NULL DEFAULT false`, + + // Der Online-Status wird nicht dauerhaft als Boolean gespeichert, damit + // abgebrochene Browser-Sitzungen nicht für immer als online erscheinen. + // Die API liefert onlineStatus=true, wenn last_seen_at höchstens zwei + // Minuten zurückliegt; der Client aktualisiert den Wert per Heartbeat. + ` + CREATE OR REPLACE VIEW user_presence AS + SELECT + id AS user_id, + last_seen_at, + last_active_at, + presence_mode, + away_after_minutes, + CASE + WHEN presence_mode = 'offline' + OR last_seen_at IS NULL + OR last_seen_at < now() - INTERVAL '2 minutes' + THEN 'offline' + WHEN presence_mode = 'away' + OR last_active_at IS NULL + OR last_active_at < now() - make_interval(mins => away_after_minutes) + THEN 'away' + ELSE 'online' + END AS status + FROM users + `, ` CREATE TABLE IF NOT EXISTS user_sessions ( @@ -436,6 +495,12 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { case_worker TEXT NOT NULL DEFAULT '', target_object TEXT NOT NULL DEFAULT '', kw_address TEXT NOT NULL DEFAULT '', + kw_latitude DOUBLE PRECISION, + kw_longitude DOUBLE PRECISION, + target_latitude DOUBLE PRECISION, + target_longitude DOUBLE PRECISION, + view_cone_fov DOUBLE PRECISION NOT NULL DEFAULT 36, + view_cone_length DOUBLE PRECISION NOT NULL DEFAULT 0, operation_leader TEXT NOT NULL DEFAULT '', operation_team TEXT NOT NULL DEFAULT '', legend TEXT NOT NULL DEFAULT '', @@ -454,6 +519,12 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { `, `ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS milestone_storage TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS kw_latitude DOUBLE PRECISION`, + `ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS kw_longitude DOUBLE PRECISION`, + `ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS target_latitude DOUBLE PRECISION`, + `ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS target_longitude DOUBLE PRECISION`, + `ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS view_cone_fov DOUBLE PRECISION NOT NULL DEFAULT 36`, + `ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS view_cone_length DOUBLE PRECISION NOT NULL DEFAULT 0`, ` CREATE TABLE IF NOT EXISTS operation_history ( @@ -485,12 +556,15 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { entity_id UUID, data JSONB NOT NULL DEFAULT '{}'::JSONB, + in_app_visible BOOLEAN NOT NULL DEFAULT true, read_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ) `, + `ALTER TABLE IF EXISTS notifications ADD COLUMN IF NOT EXISTS in_app_visible BOOLEAN NOT NULL DEFAULT true`, + ` CREATE TABLE IF NOT EXISTS milestone_settings ( id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), @@ -548,12 +622,17 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { device_updates BOOLEAN NOT NULL DEFAULT true, device_journals BOOLEAN NOT NULL DEFAULT true, system_messages BOOLEAN NOT NULL DEFAULT true, + chat_messages BOOLEAN NOT NULL DEFAULT true, + desktop_enabled BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) `, + `ALTER TABLE IF EXISTS notification_preferences ADD COLUMN IF NOT EXISTS chat_messages BOOLEAN NOT NULL DEFAULT true`, + `ALTER TABLE IF EXISTS notification_preferences ADD COLUMN IF NOT EXISTS desktop_enabled BOOLEAN NOT NULL DEFAULT false`, + ` CREATE TABLE IF NOT EXISTS feedback ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -619,15 +698,14 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { `, // ── Chat ──────────────────────────────────────────────────────────── - // Eine Konversation ist entweder ein Team-Gruppenchat (type='team', - // genau einer pro Team), ein Einzelchat zwischen zwei Personen - // (type='direct') oder ein freier Gruppenchat (type='group'). + // Eine Konversation ist entweder ein Team-Gruppenchat, Einzelchat, + // freier Gruppenchat oder ein schreibgeschützter Integrations-Channel. ` CREATE TABLE IF NOT EXISTS conversations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), type TEXT NOT NULL DEFAULT 'direct' - CHECK (type IN ('team', 'direct', 'group')), + CHECK (type IN ('team', 'direct', 'group', 'channel')), name TEXT NOT NULL DEFAULT '', team_id UUID REFERENCES teams(id) ON DELETE CASCADE, @@ -635,6 +713,13 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { -- Stabiler Schlüssel für Einzelchats (sortierte User-IDs), -- damit pro Personenpaar nur eine Konversation existiert. direct_key TEXT NOT NULL DEFAULT '', + slug TEXT NOT NULL DEFAULT '', + channel_source_name TEXT NOT NULL DEFAULT '', + channel_image TEXT NOT NULL DEFAULT '', + channel_all_users BOOLEAN NOT NULL DEFAULT false, + webhook_enabled BOOLEAN NOT NULL DEFAULT true, + webhook_token_hash TEXT NOT NULL DEFAULT '', + webhook_token_updated_at TIMESTAMPTZ, created_by UUID REFERENCES users(id) ON DELETE SET NULL, @@ -644,6 +729,15 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) `, + `ALTER TABLE IF EXISTS conversations DROP CONSTRAINT IF EXISTS conversations_type_check`, + `ALTER TABLE IF EXISTS conversations ADD CONSTRAINT conversations_type_check CHECK (type IN ('team', 'direct', 'group', 'channel'))`, + `ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS slug TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS channel_source_name TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS channel_image TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS channel_all_users BOOLEAN NOT NULL DEFAULT false`, + `ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS webhook_enabled BOOLEAN NOT NULL DEFAULT true`, + `ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS webhook_token_hash TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS webhook_token_updated_at TIMESTAMPTZ`, ` CREATE TABLE IF NOT EXISTS conversation_members ( @@ -651,6 +745,7 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, last_read_at TIMESTAMPTZ, + muted BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), @@ -658,6 +753,8 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { ) `, + `ALTER TABLE IF EXISTS conversation_members ADD COLUMN IF NOT EXISTS muted BOOLEAN NOT NULL DEFAULT false`, + ` CREATE TABLE IF NOT EXISTS messages ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -666,6 +763,8 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { sender_id UUID REFERENCES users(id) ON DELETE SET NULL, body TEXT NOT NULL DEFAULT '', + reply_to_message_id UUID REFERENCES messages(id) ON DELETE SET NULL, + forwarded_from_message_id UUID REFERENCES messages(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), edited_at TIMESTAMPTZ, @@ -673,6 +772,46 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { ) `, + `ALTER TABLE IF EXISTS messages ADD COLUMN IF NOT EXISTS reply_to_message_id UUID REFERENCES messages(id) ON DELETE SET NULL`, + `ALTER TABLE IF EXISTS messages ADD COLUMN IF NOT EXISTS forwarded_from_message_id UUID REFERENCES messages(id) ON DELETE SET NULL`, + + // Standort-Nachrichten: lat/lng sind NULL, wenn die Nachricht keinen Standort enthält. + `ALTER TABLE IF EXISTS messages ADD COLUMN IF NOT EXISTS location_lat DOUBLE PRECISION`, + `ALTER TABLE IF EXISTS messages ADD COLUMN IF NOT EXISTS location_lng DOUBLE PRECISION`, + `ALTER TABLE IF EXISTS messages ADD COLUMN IF NOT EXISTS location_label TEXT NOT NULL DEFAULT ''`, + + ` + CREATE TABLE IF NOT EXISTS message_attachments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + uploader_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + message_id UUID REFERENCES messages(id) ON DELETE CASCADE, + + file_name TEXT NOT NULL, + content_type TEXT NOT NULL DEFAULT 'application/octet-stream', + size_bytes BIGINT NOT NULL, + data BYTEA NOT NULL, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `, + + // Link-Vorschau (OpenGraph-Metadaten), serverseitig asynchron geladen. + // Eine Vorschau pro Nachricht (erste erkannte URL). + ` + CREATE TABLE IF NOT EXISTS message_link_previews ( + message_id UUID PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE, + + url TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + image_url TEXT NOT NULL DEFAULT '', + site_name TEXT NOT NULL DEFAULT '', + + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `, + `CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)`, `CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)`, `CREATE INDEX IF NOT EXISTS idx_users_last_seen_at ON users(last_seen_at)`, @@ -751,14 +890,41 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { // Genau eine Konversation pro Team bzw. pro Einzelchat-Paar. `CREATE UNIQUE INDEX IF NOT EXISTS idx_conversations_team_id_unique ON conversations(team_id) WHERE team_id IS NOT NULL`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_conversations_direct_key_unique ON conversations(direct_key) WHERE direct_key <> ''`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_conversations_channel_slug_unique ON conversations(slug) WHERE type = 'channel' AND slug <> ''`, `CREATE INDEX IF NOT EXISTS idx_conversations_type ON conversations(type)`, `CREATE INDEX IF NOT EXISTS idx_conversations_last_message_at ON conversations(last_message_at DESC)`, + ` + INSERT INTO conversations ( + type, + name, + slug, + channel_source_name, + channel_all_users, + webhook_enabled + ) + VALUES ('channel', 'Zabbix', 'zabbix', 'Zabbix', true, true) + ON CONFLICT (slug) WHERE type = 'channel' AND slug <> '' + DO NOTHING + `, + ` + INSERT INTO conversation_members (conversation_id, user_id) + SELECT c.id, u.id + FROM conversations c + CROSS JOIN users u + WHERE c.type = 'channel' + AND c.channel_all_users + ON CONFLICT (conversation_id, user_id) DO NOTHING + `, `CREATE INDEX IF NOT EXISTS idx_conversation_members_user_id ON conversation_members(user_id)`, `CREATE INDEX IF NOT EXISTS idx_conversation_members_conversation_id ON conversation_members(conversation_id)`, `CREATE INDEX IF NOT EXISTS idx_messages_conversation_created_at ON messages(conversation_id, created_at DESC, id DESC)`, `CREATE INDEX IF NOT EXISTS idx_messages_sender_id ON messages(sender_id)`, + `CREATE INDEX IF NOT EXISTS idx_messages_reply_to_message_id ON messages(reply_to_message_id)`, + `CREATE INDEX IF NOT EXISTS idx_messages_forwarded_from_message_id ON messages(forwarded_from_message_id)`, + `CREATE INDEX IF NOT EXISTS idx_message_attachments_message_id ON message_attachments(message_id)`, + `CREATE INDEX IF NOT EXISTS idx_message_attachments_uploader_id ON message_attachments(uploader_id)`, } for _, query := range queries { diff --git a/backend/user_events.go b/backend/user_events.go index 33817b8..e6c3e96 100644 --- a/backend/user_events.go +++ b/backend/user_events.go @@ -9,6 +9,10 @@ import ( ) func (s *Server) publishUserProfileEvent(eventType string, user User) { + // Detaillierte Aktivitätszeiten werden nur über autorisierte Direktchat- + // Abfragen ausgeliefert, nicht über das globale User-Event. + user.LastSeenAt = nil + data, err := json.Marshal(map[string]any{ "user": user, }) diff --git a/backend/user_sessions.go b/backend/user_sessions.go index d3e80dc..da6771c 100644 --- a/backend/user_sessions.go +++ b/backend/user_sessions.go @@ -54,6 +54,16 @@ func (s *Server) createUserSession( err := s.db.QueryRow( ctx, ` + WITH touched_user AS ( + UPDATE users + SET + last_seen_at = now(), + last_active_at = CASE + WHEN presence_mode = 'online' THEN now() + ELSE last_active_at + END + WHERE id = $1 + ) INSERT INTO user_sessions ( user_id, browser, @@ -144,11 +154,18 @@ func (s *Server) touchUserSession( _, err := s.db.Exec( ctx, ` - UPDATE user_sessions + WITH touched_session AS ( + UPDATE user_sessions + SET last_seen_at = now() + WHERE id = $1 + AND user_id = $2 + AND revoked_at IS NULL + RETURNING user_id + ) + UPDATE users SET last_seen_at = now() - WHERE id = $1 - AND user_id = $2 - AND revoked_at IS NULL + FROM touched_session + WHERE users.id = touched_session.user_id `, sessionID, userID, diff --git a/backend/user_settings.go b/backend/user_settings.go index a757c0d..84134f1 100644 --- a/backend/user_settings.go +++ b/backend/user_settings.go @@ -18,8 +18,6 @@ type updateSettingsAccountRequest struct { Username string `json:"username"` Email string `json:"email"` Avatar string `json:"avatar"` - Unit string `json:"unit"` - Group string `json:"group"` } type updateSettingsPasswordRequest struct { @@ -49,8 +47,6 @@ func (s *Server) handleUpdateSettingsAccount(w http.ResponseWriter, r *http.Requ email := normalizeEmail(payload.Email) displayName := strings.TrimSpace(payload.DisplayName) avatar := strings.TrimSpace(payload.Avatar) - unit := strings.TrimSpace(payload.Unit) - group := strings.TrimSpace(payload.Group) if username == "" { writeSettingsError(w, http.StatusBadRequest, "Benutzername ist erforderlich") @@ -66,10 +62,6 @@ func (s *Server) handleUpdateSettingsAccount(w http.ResponseWriter, r *http.Requ displayName = username } - if group == "" { - group = "user" - } - commandTag, err := s.db.Exec( r.Context(), ` @@ -79,17 +71,13 @@ func (s *Server) handleUpdateSettingsAccount(w http.ResponseWriter, r *http.Requ display_name = $2, email = $3, avatar = $4, - unit = $5, - user_group = $6, updated_at = now() - WHERE id = $7 + WHERE id = $5 `, username, displayName, email, avatar, - unit, - group, userID, ) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1c3255f..ed013ca 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,8 +1,8 @@ // frontend/src/App.tsx -import { useEffect, useState } from 'react' -import { Navigate, Route, Routes, useNavigate } from 'react-router' -import { NotificationProvider } from './components/Notifications' +import { useCallback, useEffect, useState } from 'react' +import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router' +import { useNotifications } from './components/Notifications' import LoginPage from './pages/LoginPage' import AppLayout from './components/AppLayout' import DashboardPage from './pages/DashboardPage' @@ -21,6 +21,8 @@ import Feedback from './components/Feedback' import MilestoneStoragePage from './pages/operations/milestone-storage/MilestoneStoragePage' import MilestoneDevicesPage from './pages/operations/milestone-devices/MilestoneDevicesPage' import { hasRight } from './components/permissions' +import ChatPage from './pages/chat/ChatPage' +import { recordNavigation } from './utils/activityLog' const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080' @@ -65,6 +67,23 @@ function getUserFromNotification(notification: Notification) { return user as User } +function resolveNotificationImage(value: unknown) { + if (typeof value !== 'string' || !value.trim()) { + return '' + } + + const image = value.trim() + if (image.startsWith('data:') || image.startsWith('blob:')) { + return image + } + + try { + return new URL(image, API_URL).toString() + } catch { + return new URL('/favicon.svg', window.location.origin).toString() + } +} + function playNotificationSound(soundName?: string) { if (!soundName) { return @@ -96,31 +115,57 @@ function RequireRight({ function App() { const navigate = useNavigate() + const location = useLocation() + const { showToast } = useNotifications() const [user, setUser] = useState(null) const [isLoading, setIsLoading] = useState(true) const [notifications, setNotifications] = useState([]) + const [unreadChatCount, setUnreadChatCount] = useState(0) const [unreadJournalOperationIds, setUnreadJournalOperationIds] = useState>( () => new Set(), ) + const currentUserId = user?.id + const awayAfterMinutes = user?.awayAfterMinutes const unreadNotificationCount = notifications.filter( (notification) => !notification.readAt, ).length + const loadUnreadChatCount = useCallback(async () => { + try { + const response = await fetch(`${API_URL}/chat/unread`, { + credentials: 'include', + }) + if (!response.ok) { + return + } + + const data = await response.json() + setUnreadChatCount(Number(data.unreadCount) || 0) + } catch { + // Der Zähler wird beim nächsten Chat- oder Notification-Ereignis erneut geladen. + } + }, []) + useEffect(() => { void loadUser() }, []) useEffect(() => { - if (!user) { + recordNavigation(location.pathname) + }, [location.pathname]) + + useEffect(() => { + if (!currentUserId) { setNotifications([]) setUnreadJournalOperationIds(new Set()) return } void loadNotifications() + void loadUnreadChatCount() void loadUnreadJournalOperations() const eventSource = new EventSource(`${API_URL}/events`, { @@ -132,6 +177,89 @@ function App() { return } + if (notification.entityType === 'chat') { + const data = getNotificationData(notification) + const senderAvatar = resolveNotificationImage(data.senderAvatar) + const desktopIcon = + senderAvatar || + new URL('/favicon.svg', window.location.origin).toString() + const senderName = + typeof data.senderName === 'string' ? data.senderName : notification.title + const isChannel = data.conversationType === 'channel' + const chatPath = isChannel + ? `/chat/channel/${notification.entityId}` + : `/chat/${notification.entityId}` + + if ( + notification.desktop && + 'Notification' in window && + Notification.permission === 'granted' + ) { + try { + const desktopNotification = new Notification(notification.title, { + body: notification.message, + icon: desktopIcon, + badge: desktopIcon, + tag: `chat-${notification.entityId}`, + requireInteraction: true, + }) + + desktopNotification.onclick = () => { + window.focus() + navigate(chatPath) + desktopNotification.close() + } + } catch { + // Das sichtbare Browser-Popup darunter bleibt als Fallback erhalten. + } + } + + if ( + notification.inAppEnabled !== false && + document.visibilityState === 'visible' + ) { + showToast({ + title: notification.title, + message: notification.message, + imageUrl: senderAvatar, + imageName: senderName, + actionLabel: 'Öffnen', + onAction: () => navigate(chatPath), + ...(isChannel + ? {} + : { + replyPlaceholder: `An ${senderName} antworten...`, + onReply: async (message: string) => { + const response = await fetch( + `${API_URL}/chat/conversations/${notification.entityId}/messages`, + { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ body: message }), + }, + ) + if (!response.ok) { + const responseData = await response.json().catch(() => null) + throw new Error( + responseData?.error || + 'Antwort konnte nicht gesendet werden.', + ) + } + }, + }), + autoClose: false, + }) + } + + playNotificationSound(notification.soundName) + return + } + + if (notification.inAppEnabled === false) { + return + } + playNotificationSound(notification.soundName) setNotifications((currentNotifications) => [ @@ -167,6 +295,10 @@ function App() { addVisibleNotification(notification) dispatchEntityEvent(notification) + + if (notification.entityType === 'chat') { + void loadUnreadChatCount() + } } function handleOperationEvent(event: MessageEvent) { @@ -248,7 +380,103 @@ function App() { eventSource.removeEventListener('user-event', handleUserEvent) eventSource.close() } - }, [user]) + }, [currentUserId, loadUnreadChatCount, navigate, showToast]) + + useEffect(() => { + if (!currentUserId) { + return + } + + function handleUnreadChatChange() { + void loadUnreadChatCount() + } + + window.addEventListener('chat-unread-changed', handleUnreadChatChange) + return () => { + window.removeEventListener('chat-unread-changed', handleUnreadChatChange) + } + }, [currentUserId, loadUnreadChatCount]) + + useEffect(() => { + if (!currentUserId) { + return + } + + let idle = false + let lastActiveHeartbeat = 0 + let idleTimer: number | undefined + const awayAfterMilliseconds = + Math.max(1, awayAfterMinutes || 3) * 60_000 + + function sendHeartbeat(active: boolean) { + void fetch(`${API_URL}/presence`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ active }), + }) + } + + function scheduleIdleTimer() { + if (idleTimer !== undefined) { + window.clearTimeout(idleTimer) + } + + idleTimer = window.setTimeout(() => { + idle = true + sendHeartbeat(false) + }, awayAfterMilliseconds) + } + + function handleActivity() { + const now = Date.now() + idle = false + scheduleIdleTimer() + + if (now - lastActiveHeartbeat >= 15_000) { + lastActiveHeartbeat = now + sendHeartbeat(true) + } + } + + sendHeartbeat(true) + lastActiveHeartbeat = Date.now() + scheduleIdleTimer() + + const intervalId = window.setInterval(() => { + sendHeartbeat(!idle && document.visibilityState === 'visible') + }, 45_000) + + function handleVisibilityChange() { + if (document.visibilityState === 'visible') { + handleActivity() + } + } + + const activityEvents: Array = [ + 'pointerdown', + 'keydown', + 'mousemove', + 'touchstart', + 'scroll', + ] + + activityEvents.forEach((eventName) => { + window.addEventListener(eventName, handleActivity, { passive: true }) + }) + document.addEventListener('visibilitychange', handleVisibilityChange) + + return () => { + window.clearInterval(intervalId) + if (idleTimer !== undefined) { + window.clearTimeout(idleTimer) + } + activityEvents.forEach((eventName) => { + window.removeEventListener(eventName, handleActivity) + }) + document.removeEventListener('visibilitychange', handleVisibilityChange) + } + }, [awayAfterMinutes, currentUserId]) async function loadUser() { try { @@ -352,6 +580,16 @@ function App() { } function handleNotificationClick(notification: Notification) { + if (notification.entityType === 'chat' && notification.entityId) { + const data = getNotificationData(notification) + navigate( + data.conversationType === 'channel' + ? `/chat/channel/${notification.entityId}` + : `/chat/${notification.entityId}`, + ) + return + } + if ( notification.type === 'operation.created' || notification.entityType === 'operation' @@ -369,6 +607,7 @@ function App() { } finally { setUser(null) setNotifications([]) + setUnreadChatCount(0) setUnreadJournalOperationIds(new Set()) } } @@ -396,10 +635,11 @@ function App() { } return ( - - } /> + } /> + } + /> + } + /> + } + /> } /> - - + ) } -export default App \ No newline at end of file +export default App diff --git a/frontend/src/components/ActivityLogView.tsx b/frontend/src/components/ActivityLogView.tsx new file mode 100644 index 0000000..85bff8b --- /dev/null +++ b/frontend/src/components/ActivityLogView.tsx @@ -0,0 +1,118 @@ +// frontend/src/components/ActivityLogView.tsx + +import { + ArrowLongRightIcon, + CursorArrowRaysIcon, + ExclamationTriangleIcon, +} from '@heroicons/react/24/outline' +import type { ActivityLogEntry, ActivityLogType } from '../utils/activityLog' + +function classNames(...classes: Array) { + return classes.filter(Boolean).join(' ') +} + +function formatTime(value: string) { + const date = new Date(value) + if (Number.isNaN(date.getTime())) { + return '' + } + + return new Intl.DateTimeFormat('de-DE', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }).format(date) +} + +const typeMeta: Record< + ActivityLogType, + { icon: typeof CursorArrowRaysIcon; iconClassName: string } +> = { + action: { + icon: CursorArrowRaysIcon, + iconClassName: 'text-indigo-500 dark:text-indigo-400', + }, + navigation: { + icon: ArrowLongRightIcon, + iconClassName: 'text-sky-500 dark:text-sky-400', + }, + error: { + icon: ExclamationTriangleIcon, + iconClassName: 'text-red-500 dark:text-red-400', + }, +} + +export default function ActivityLogView({ + entries, + showPath = true, + emptyLabel = 'Keine Aktivitäten aufgezeichnet.', +}: { + entries: ActivityLogEntry[] + showPath?: boolean + emptyLabel?: string +}) { + if (entries.length === 0) { + return ( +

+ {emptyLabel} +

+ ) + } + + return ( +
    + {entries.map((entry) => { + const meta = typeMeta[entry.type] ?? typeMeta.action + const Icon = meta.icon + const isError = entry.type === 'error' + const stack = + entry.details && typeof entry.details.stack === 'string' + ? entry.details.stack + : '' + + return ( +
  1. + + +
    +

    + {entry.message} +

    + + {showPath && entry.path && ( +

    + {entry.path} +

    + )} + + {stack && ( +
    +                  {stack}
    +                
    + )} +
    + + +
  2. + ) + })} +
+ ) +} diff --git a/frontend/src/components/AddressCombobox.tsx b/frontend/src/components/AddressCombobox.tsx index 0b7a082..34c93c6 100644 --- a/frontend/src/components/AddressCombobox.tsx +++ b/frontend/src/components/AddressCombobox.tsx @@ -21,14 +21,21 @@ export type AddressSuggestion = { lat: number lon: number type?: string + hasHouseNumber?: boolean geojson?: GeoJsonObject | null } -type Coordinates = { +export type AddressCoordinates = { lat: number lon: number } +export type AddressArea = { + center: AddressCoordinates + hasHouseNumber: boolean + geojson: GeoJsonObject | null +} + type ReverseGeocodeResponse = { suggestion?: AddressSuggestion } @@ -39,8 +46,12 @@ type AddressComboboxProps = { label: string value: string onChange: (value: string) => void + position?: AddressCoordinates | null + onPositionChange?: (position: AddressCoordinates | null) => void + onAreaChange?: (area: AddressArea | null) => void placeholder?: string mapVisible?: boolean + showMap?: boolean } const inputClassName = @@ -73,6 +84,18 @@ function normalizeAddressSuggestion( ...suggestion, lat, lon, + hasHouseNumber: suggestion.hasHouseNumber === true, + } +} + +function createAddressArea( + suggestion: AddressSuggestion | null, + center: AddressCoordinates, +): AddressArea { + return { + center, + hasHouseNumber: suggestion?.hasHouseNumber === true, + geojson: suggestion?.geojson ?? null, } } @@ -82,8 +105,12 @@ export default function AddressCombobox({ label, value, onChange, + position, + onPositionChange, + onAreaChange, placeholder = 'Adresse suchen', mapVisible = true, + showMap = true, }: AddressComboboxProps) { const [query, setQuery] = useState(value) const [suggestions, setSuggestions] = useState([]) @@ -96,6 +123,21 @@ export default function AddressCombobox({ const [mapFocusKey, setMapFocusKey] = useState(0) const [isSearching, setIsSearching] = useState(false) const [searchError, setSearchError] = useState(null) + const controlledMarkerPosition = + position && + Number.isFinite(position.lat) && + Number.isFinite(position.lon) + ? position + : null + const effectiveMarkerPosition = + position === undefined ? markerPosition : controlledMarkerPosition + const hasControlledMarkerPosition = controlledMarkerPosition !== null + const highlightedSuggestion = + selectedSuggestion?.geojson + ? selectedSuggestion + : previewSuggestion?.geojson + ? previewSuggestion + : null const previousValueRef = useRef(value) const initializedAddressRef = useRef('') @@ -104,6 +146,26 @@ export default function AddressCombobox({ const reverseGeocodeAbortRef = useRef(null) const internalValueUpdateRef = useRef(false) + useEffect(() => { + if ( + !highlightedSuggestion?.geojson || + !effectiveMarkerPosition + ) { + return + } + + onAreaChange?.( + createAddressArea(highlightedSuggestion, { + lat: highlightedSuggestion.lat, + lon: highlightedSuggestion.lon, + }), + ) + }, [ + effectiveMarkerPosition, + highlightedSuggestion, + onAreaChange, + ]) + useEffect(() => { if (value !== previousValueRef.current) { setQuery(value) @@ -136,7 +198,7 @@ export default function AddressCombobox({ return } - if (markerPosition) { + if (effectiveMarkerPosition) { setMapFocusKey((currentKey) => currentKey + 1) return } @@ -144,7 +206,7 @@ export default function AddressCombobox({ initializedAddressRef.current = '' void loadInitialMarkerForAddress(value) - }, [mapVisible, markerPosition, value]) + }, [mapVisible, effectiveMarkerPosition, value]) useEffect(() => { const trimmedValue = value.trim() @@ -154,7 +216,7 @@ export default function AddressCombobox({ return } - if (markerPosition) { + if (effectiveMarkerPosition) { return } @@ -165,7 +227,63 @@ export default function AddressCombobox({ return () => { abortController.abort() } - }, [value, markerPosition]) + }, [value, effectiveMarkerPosition]) + + useEffect(() => { + if ( + !hasControlledMarkerPosition || + value.trim().length < 3 || + selectedSuggestion + ) { + return + } + + const coordinates = { + lat: controlledMarkerPosition.lat, + lon: controlledMarkerPosition.lon, + } + const abortController = new AbortController() + + onAreaChange?.(createAddressArea(null, coordinates)) + + void (async () => { + try { + const response = await fetch( + `${API_URL}/address-reverse?lat=${encodeURIComponent( + coordinates.lat, + )}&lon=${encodeURIComponent( + coordinates.lon, + )}&q=${encodeURIComponent(value.trim())}`, + { + credentials: 'include', + signal: abortController.signal, + }, + ) + + if (!response.ok) { + return + } + + const data = (await response.json()) as ReverseGeocodeResponse + const suggestion = data.suggestion + ? normalizeAddressSuggestion(data.suggestion) + : null + + onAreaChange?.(createAddressArea(suggestion, coordinates)) + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + return + } + } + })() + + return () => abortController.abort() + }, [ + hasControlledMarkerPosition, + value, + onAreaChange, + selectedSuggestion, + ]) async function loadInitialMarkerForAddress( address: string, @@ -177,7 +295,10 @@ export default function AddressCombobox({ return } - if (initializedAddressRef.current === trimmedAddress && markerPosition) { + if ( + initializedAddressRef.current === trimmedAddress && + effectiveMarkerPosition + ) { return } @@ -207,7 +328,7 @@ export default function AddressCombobox({ const reverseSuggestion = await reverseGeocodeCoordinates({ lat: firstSuggestion.lat, lon: firstSuggestion.lon, - }) + }, trimmedAddress, firstSuggestion.id) const nextSuggestion: AddressSuggestion = { ...firstSuggestion, @@ -222,6 +343,16 @@ export default function AddressCombobox({ lat: nextSuggestion.lat, lon: nextSuggestion.lon, }) + onPositionChange?.({ + lat: nextSuggestion.lat, + lon: nextSuggestion.lon, + }) + onAreaChange?.( + createAddressArea(nextSuggestion, { + lat: nextSuggestion.lat, + lon: nextSuggestion.lon, + }), + ) setMapFocusKey((currentKey) => currentKey + 1) } catch (error) { if (error instanceof DOMException && error.name === 'AbortError') { @@ -231,7 +362,9 @@ export default function AddressCombobox({ } async function reverseGeocodeCoordinates( - coordinates: Coordinates, + coordinates: AddressCoordinates, + address = value, + osmIdentifier = '', ): Promise { reverseGeocodeAbortRef.current?.abort() @@ -242,7 +375,11 @@ export default function AddressCombobox({ const response = await fetch( `${API_URL}/address-reverse?lat=${encodeURIComponent( coordinates.lat, - )}&lon=${encodeURIComponent(coordinates.lon)}`, + )}&lon=${encodeURIComponent( + coordinates.lon, + )}&q=${encodeURIComponent( + address.trim(), + )}&osm_id=${encodeURIComponent(osmIdentifier)}`, { credentials: 'include', signal: abortController.signal, @@ -279,14 +416,16 @@ export default function AddressCombobox({ } } - async function handleMapPositionChange(coordinates: Coordinates) { + async function handleMapPositionChange(coordinates: AddressCoordinates) { setMarkerPosition(coordinates) + onPositionChange?.(coordinates) setSelectedSuggestion(null) setPreviewSuggestion(null) const suggestion = await reverseGeocodeCoordinates(coordinates) if (!suggestion) { + onAreaChange?.(createAddressArea(null, coordinates)) return } @@ -296,6 +435,7 @@ export default function AddressCombobox({ setQuery(suggestion.label) setSelectedSuggestion(suggestion) setPreviewSuggestion(suggestion) + onAreaChange?.(createAddressArea(suggestion, coordinates)) onChange(suggestion.label) } @@ -372,6 +512,8 @@ export default function AddressCombobox({ setSelectedSuggestion(null) setPreviewSuggestion(null) setMarkerPosition(null) + onPositionChange?.(null) + onAreaChange?.(null) onChange(nextValue) } @@ -390,12 +532,22 @@ export default function AddressCombobox({ lat: normalizedSuggestion.lat, lon: normalizedSuggestion.lon, }) + onPositionChange?.({ + lat: normalizedSuggestion.lat, + lon: normalizedSuggestion.lon, + }) + onAreaChange?.( + createAddressArea(normalizedSuggestion, { + lat: normalizedSuggestion.lat, + lon: normalizedSuggestion.lon, + }), + ) setMapFocusKey((currentKey) => currentKey + 1) const reverseSuggestion = await reverseGeocodeCoordinates({ lat: normalizedSuggestion.lat, lon: normalizedSuggestion.lon, - }) + }, normalizedSuggestion.label, normalizedSuggestion.id) const nextSuggestion: AddressSuggestion = { ...normalizedSuggestion, @@ -409,6 +561,12 @@ export default function AddressCombobox({ setQuery(nextSuggestion.label) setSelectedSuggestion(nextSuggestion) setPreviewSuggestion(nextSuggestion) + onAreaChange?.( + createAddressArea(nextSuggestion, { + lat: nextSuggestion.lat, + lon: nextSuggestion.lon, + }), + ) onChange(nextSuggestion.label) } @@ -472,14 +630,16 @@ export default function AddressCombobox({ - + {showMap && ( + + )} ) -} \ No newline at end of file +} diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx index fbf8c81..d6cd4fd 100644 --- a/frontend/src/components/AppLayout.tsx +++ b/frontend/src/components/AppLayout.tsx @@ -10,16 +10,20 @@ import type { User } from './types' type AppLayoutProps = { user: User + onUserUpdated: (user: User) => void onLogout: () => void | Promise children: ReactNode notificationCenter?: ReactNode + unreadChatCount?: number } export default function AppLayout({ user, + onUserUpdated, onLogout, children, notificationCenter, + unreadChatCount = 0, }: AppLayoutProps) { const location = useLocation() @@ -48,11 +52,13 @@ export default function AppLayout({ sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} onNavigate={handleNavigation} + unreadChatCount={unreadChatCount} />
setSidebarOpen(true)} notificationCenter={notificationCenter} @@ -85,4 +91,4 @@ export default function AppLayout({
) -} \ No newline at end of file +} diff --git a/frontend/src/components/Avatar.tsx b/frontend/src/components/Avatar.tsx index 1d6b891..d35144c 100644 --- a/frontend/src/components/Avatar.tsx +++ b/frontend/src/components/Avatar.tsx @@ -4,7 +4,8 @@ import type { ImgHTMLAttributes } from 'react' export type AvatarSize = 6 | 8 | 9 | 10 | 12 | 14 | 16 export type AvatarShape = 'circle' | 'rounded' -export type AvatarStatus = 'gray' | 'red' | 'green' +export type AvatarStatus = 'gray' | 'red' | 'green' | 'amber' +export type PresenceStatus = 'online' | 'away' | 'offline' export type AvatarStatusPosition = 'top' | 'bottom' export type AvatarProps = Omit, 'size'> & { @@ -19,6 +20,9 @@ export type AvatarProps = Omit, 'size'> & { * Online-Status der Person. true = online (grün), false = offline (grau). * Wird ignoriert, wenn `status` explizit gesetzt ist. */ + onlineStatus?: boolean + presenceStatus?: PresenceStatus + /** @deprecated Nutze `onlineStatus`. */ online?: boolean showPlaceholderIcon?: boolean wrapperClassName?: string @@ -62,6 +66,7 @@ const statusColorClassNames: Record = { gray: 'bg-gray-300 dark:bg-gray-500', red: 'bg-red-400 dark:bg-red-500', green: 'bg-green-400 dark:bg-green-500', + amber: 'bg-amber-400 dark:bg-amber-500', } function getInitials(name?: string, initials?: string) { @@ -104,6 +109,8 @@ export default function Avatar({ shape = 'circle', status, statusPosition = 'bottom', + onlineStatus, + presenceStatus, online, showPlaceholderIcon = true, wrapperClassName, @@ -114,10 +121,35 @@ export default function Avatar({ const avatarInitials = getInitials(name, initials) // `status` hat Vorrang; ansonsten leitet sich der Punkt aus `online` ab. + const resolvedOnlineStatus = onlineStatus ?? online + const presenceAvatarStatus: AvatarStatus | undefined = + presenceStatus === 'online' + ? 'green' + : presenceStatus === 'away' + ? 'amber' + : presenceStatus === 'offline' + ? 'gray' + : undefined const effectiveStatus: AvatarStatus | undefined = - status ?? (online === undefined ? undefined : online ? 'green' : 'gray') + status ?? + presenceAvatarStatus ?? + (resolvedOnlineStatus === undefined + ? undefined + : resolvedOnlineStatus + ? 'green' + : 'gray') const statusLabel = - online === undefined ? undefined : online ? 'Online' : 'Offline' + presenceStatus === 'online' + ? 'Online' + : presenceStatus === 'away' + ? 'Abwesend' + : presenceStatus === 'offline' + ? 'Offline' + : resolvedOnlineStatus === undefined + ? undefined + : resolvedOnlineStatus + ? 'Online' + : 'Offline' const avatar = src ? ( + {avatar} ) -} \ No newline at end of file +} diff --git a/frontend/src/components/Feedback.tsx b/frontend/src/components/Feedback.tsx index 42054b0..4b8420f 100644 --- a/frontend/src/components/Feedback.tsx +++ b/frontend/src/components/Feedback.tsx @@ -1,54 +1,96 @@ -// frontend/src/pages/Feedback.tsx +// frontend/src/components/Feedback.tsx -import { useState, type FormEvent } from 'react' -import Textarea from '../components/Textarea' +import { useMemo, useState, type FormEvent } from 'react' +import { + ChatBubbleBottomCenterTextIcon, + CheckCircleIcon, + CursorArrowRaysIcon, + ExclamationTriangleIcon, + ShieldCheckIcon, +} from '@heroicons/react/24/outline' +import Button from './Button' +import ActivityLogView from './ActivityLogView' +import { getActivityLog, type ActivityLogEntry } from '../utils/activityLog' +import { getCurrentSessionInfo } from '../utils/sessionInfo' const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080' +const MAX_LENGTH = 5000 + +async function collectClientMeta() { + try { + const sessionInfo = await getCurrentSessionInfo() + + return { + ...sessionInfo, + url: window.location.href, + userAgent: window.navigator.userAgent, + language: window.navigator.language, + screen: `${window.screen.width}×${window.screen.height}`, + viewport: `${window.innerWidth}×${window.innerHeight}`, + collectedAt: new Date().toISOString(), + } + } catch { + return { + url: window.location.href, + userAgent: window.navigator.userAgent, + collectedAt: new Date().toISOString(), + } + } +} export default function Feedback() { + const [logSnapshot, setLogSnapshot] = useState(() => + getActivityLog(), + ) const [message, setMessage] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) const [error, setError] = useState(null) - const [successMessage, setSuccessMessage] = useState(null) + const [submitted, setSubmitted] = useState(false) + + const errorCount = useMemo( + () => logSnapshot.filter((entry) => entry.type === 'error').length, + [logSnapshot], + ) + const actionCount = logSnapshot.length - errorCount async function handleSubmit(event: FormEvent) { event.preventDefault() const trimmedMessage = message.trim() - if (!trimmedMessage) { setError('Bitte gib dein Feedback ein.') - setSuccessMessage(null) return } setIsSubmitting(true) setError(null) - setSuccessMessage(null) try { + const client = await collectClientMeta() + const response = await fetch(`${API_URL}/feedback`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ message: trimmedMessage, + clientLog: logSnapshot, + client, }), }) if (!response.ok) { const errorData = await response.json().catch(() => null) - throw new Error(errorData?.error ?? 'Feedback konnte nicht gesendet werden') + throw new Error( + errorData?.error ?? 'Feedback konnte nicht gesendet werden', + ) } - setMessage('') - setSuccessMessage('Danke, dein Feedback wurde gespeichert.') - } catch (error) { + setSubmitted(true) + } catch (submitError) { setError( - error instanceof Error - ? error.message + submitError instanceof Error + ? submitError.message : 'Feedback konnte nicht gesendet werden', ) } finally { @@ -56,56 +98,141 @@ export default function Feedback() { } } - return ( -
-
-
-

- Feedback -

+ function startNewFeedback() { + setSubmitted(false) + setMessage('') + setError(null) + setLogSnapshot(getActivityLog()) + } -

- Teile uns mit, wenn dir etwas auffällt, etwas nicht funktioniert oder du eine Idee zur Verbesserung hast. - Beim Absenden wird automatisch ein technisches Log mit deinem Benutzerkonto angehängt. -

+ return ( +
+
+
+ + +
+

+ Feedback +

+

+ Fällt dir etwas auf, funktioniert etwas nicht oder hast du eine + Idee? Beim Absenden wird automatisch das unten sichtbare Protokoll + angehängt. +

+
- {successMessage && ( -
- {successMessage} + {submitted ? ( +
+ + +

+ Danke für dein Feedback! +

+

+ Dein Feedback wurde zusammen mit dem Protokoll gespeichert. Unser + Team schaut es sich an. +

+
+ +
+ ) : ( + <> + {error && ( +
+ + {error} +
+ )} + +
+ +