// backend/chat_groups.go // // Gruppenchats (Typ 'group') sowie selbstlöschende Nachrichten und die daraus // resultierenden System-Nachrichten. Gruppen werden ausschließlich vom // Ersteller/Owner (conversations.created_by) verwaltet. package main import ( "context" "encoding/json" "errors" "fmt" "net/http" "strings" "time" "github.com/jackc/pgx/v5" ) const ( maxGroupNameLength = 80 maxGroupImageBytes = 3 * 1024 * 1024 ) // Erlaubte Selbstlösch-Zeiträume in Sekunden: Aus, 1 Stunde, 1 Tag, 1 Woche. var allowedDisappearingSeconds = map[int]bool{ 0: true, 3600: true, 86400: true, 604800: true, } type createGroupRequest struct { Name string `json:"name"` Image string `json:"image"` MemberIDs []string `json:"memberIds"` IsPublic bool `json:"isPublic"` } type updateGroupRequest struct { Name *string `json:"name"` Image *string `json:"image"` IsPublic *bool `json:"isPublic"` } type addGroupMembersRequest struct { UserIDs []string `json:"userIds"` } type updateDisappearingRequest struct { Seconds int `json:"seconds"` } func conversationActorName(u User) string { if name := strings.TrimSpace(u.DisplayName); name != "" { return name } if name := strings.TrimSpace(u.Username); name != "" { return name } return u.Email } func disappearingDurationLabel(seconds int) string { switch seconds { case 3600: return "1 Stunde" case 86400: return "1 Tag" case 604800: return "1 Woche" default: return "" } } // startDisappearingMessagesMonitor löscht periodisch abgelaufene // selbstlöschende Nachrichten (Anhänge werden per FK ON DELETE CASCADE entfernt). func (s *Server) startDisappearingMessagesMonitor(ctx context.Context) { s.reconcileDisappearingMessages(ctx) go func() { ticker := time.NewTicker(60 * time.Second) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: s.reconcileDisappearingMessages(ctx) } } }() } func (s *Server) reconcileDisappearingMessages(ctx context.Context) { s.deleteExpiredMessages(ctx) } func (s *Server) deleteExpiredMessages(ctx context.Context) { if _, err := s.db.Exec( ctx, `DELETE FROM messages WHERE expires_at IS NOT NULL AND expires_at <= now()`, ); err != nil { logError("Abgelaufene Nachrichten konnten nicht gelöscht werden", err, nil) } } func canManageGroup(user User, ownerID string) bool { return ownerID == user.ID || userHasRight(user, "admin") } func canManageTeamGroup(user User) bool { return userHasRight(user, "teams:write") } func (s *Server) canManageGroupConversation(ctx context.Context, conversationID string, user User) bool { if strings.TrimSpace(conversationID) == "" || strings.TrimSpace(user.ID) == "" { return false } var ownerID string err := s.db.QueryRow( ctx, ` SELECT COALESCE(created_by::TEXT, '') FROM conversations WHERE id = $1 AND type = 'group' AND team_id IS NULL `, conversationID, ).Scan(&ownerID) return err == nil && canManageGroup(user, ownerID) } func (s *Server) userDisplayName(ctx context.Context, userID string) string { var name string err := s.db.QueryRow( ctx, `SELECT COALESCE(NULLIF(display_name, ''), NULLIF(username, ''), email, 'Unbekannt') FROM users WHERE id = $1`, userID, ).Scan(&name) if err != nil || strings.TrimSpace(name) == "" { return "Unbekannt" } return name } // createSystemMessage legt eine nicht ablaufende System-Nachricht im Verlauf an. func (s *Server) createSystemMessage( ctx context.Context, conversationID string, actorID string, event string, body string, data map[string]any, ) (ChatMessage, error) { payload := map[string]any{"event": event} for key, value := range data { payload[key] = value } eventJSON, err := json.Marshal(payload) if err != nil { return ChatMessage{}, err } var messageID string err = s.db.QueryRow( ctx, ` INSERT INTO messages (conversation_id, sender_id, body, message_type, system_event) VALUES ($1, NULLIF($2, '')::UUID, $3, 'system', $4::JSONB) RETURNING id::TEXT `, conversationID, actorID, body, string(eventJSON), ).Scan(&messageID) if err != nil { return ChatMessage{}, err } if _, err := s.db.Exec( ctx, `UPDATE conversations SET last_message_at = now(), updated_at = now() WHERE id = $1`, conversationID, ); err != nil { return ChatMessage{}, err } return s.getChatMessage(ctx, messageID) } // emitSystemMessage erzeugt eine System-Nachricht und verteilt sie an alle // Mitglieder (ohne Web-Push, siehe publishChatMessage). func (s *Server) emitSystemMessage( ctx context.Context, conversationID string, actorID string, event string, body string, data map[string]any, ) { message, err := s.createSystemMessage(ctx, conversationID, actorID, event, body, data) if err != nil { return } s.publishChatMessage(ctx, message, "") } func (s *Server) handleCreateGroup(w http.ResponseWriter, r *http.Request) { user, ok := userFromContext(r.Context()) if !ok { writeError(w, http.StatusUnauthorized, "Unauthorized") return } var input createGroupRequest if err := readJSON(r, &input); err != nil { writeError(w, http.StatusBadRequest, "Ungültige Anfrage") return } name := strings.TrimSpace(input.Name) if name == "" || len([]rune(name)) > maxGroupNameLength { writeError(w, http.StatusBadRequest, "Gruppenname muss 1–80 Zeichen lang sein") return } image := strings.TrimSpace(input.Image) if len(image) > maxGroupImageBytes { writeError(w, http.StatusBadRequest, "Das Gruppenbild ist zu groß") return } memberIDs := map[string]struct{}{user.ID: {}} for _, id := range input.MemberIDs { if id = strings.TrimSpace(id); id != "" { memberIDs[id] = struct{}{} } } tx, err := s.db.Begin(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht erstellt werden") return } defer tx.Rollback(r.Context()) var conversationID string err = tx.QueryRow( r.Context(), ` INSERT INTO conversations (type, name, channel_image, created_by, is_public) VALUES ('group', $1, $2, $3, $4) RETURNING id::TEXT `, name, image, user.ID, input.IsPublic, ).Scan(&conversationID) if err != nil { writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht erstellt werden") return } for id := range memberIDs { if _, err := tx.Exec( r.Context(), ` INSERT INTO conversation_members (conversation_id, user_id) SELECT $1, id FROM users WHERE id = $2 ON CONFLICT (conversation_id, user_id) DO NOTHING `, conversationID, id, ); err != nil { writeError(w, http.StatusInternalServerError, "Gruppenmitglieder konnten nicht gespeichert werden") return } } if err := tx.Commit(r.Context()); err != nil { writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht gespeichert werden") return } // Erst die Konversation an alle Mitglieder verteilen (damit der Chat in der // Seitenleiste erscheint), dann die "erstellt"-System-Nachricht senden. s.publishConversationUpdate(r.Context(), conversationID) s.emitSystemMessage( r.Context(), conversationID, user.ID, "group_created", fmt.Sprintf("%s hat die Gruppe erstellt.", conversationActorName(user)), nil, ) conversation, err := s.getChatConversation(r.Context(), conversationID, user.ID, user.ShowLastSeen) if err != nil { writeError(w, http.StatusInternalServerError, "Chat konnte nicht geladen werden") return } writeJSON(w, http.StatusCreated, map[string]any{"conversation": conversation}) } func (s *Server) handleUpdateGroup(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 updateGroupRequest if err := readJSON(r, &input); err != nil { writeError(w, http.StatusBadRequest, "Ungültige Anfrage") return } var conversationType, currentName, currentImage, ownerID, teamID string var currentIsPublic bool if err := s.db.QueryRow( r.Context(), ` SELECT type, name, channel_image, COALESCE(created_by::TEXT, ''), COALESCE(team_id::TEXT, ''), is_public FROM conversations WHERE id = $1 AND type IN ('direct', 'group') `, conversationID, ).Scan(&conversationType, ¤tName, ¤tImage, &ownerID, &teamID, ¤tIsPublic); err != nil { writeError(w, http.StatusNotFound, "Chat wurde nicht gefunden") return } if conversationType == "direct" { if !s.isConversationMember(r.Context(), conversationID, user.ID) { writeError(w, http.StatusForbidden, "Nur Mitglieder dürfen diesen Chat verwalten") return } if input.Name != nil || input.Image != nil { writeError(w, http.StatusBadRequest, "Name und Bild können bei Einzelchats nicht geändert werden") return } } else if teamID != "" { if !canManageTeamGroup(user) { writeError(w, http.StatusForbidden, "Keine Berechtigung zum Verwalten des Team-Gruppenchats") return } if input.Name != nil { writeError(w, http.StatusBadRequest, "Der Name des Team-Gruppenchats wird über das Team verwaltet") return } } else if !canManageGroup(user, ownerID) { writeError(w, http.StatusForbidden, "Nur der Ersteller oder ein Administrator darf die Gruppe verwalten") return } changed := false if input.Name != nil { newName := strings.TrimSpace(*input.Name) if newName == "" || len([]rune(newName)) > maxGroupNameLength { writeError(w, http.StatusBadRequest, "Gruppenname muss 1–80 Zeichen lang sein") return } if newName != currentName { if _, err := s.db.Exec( r.Context(), `UPDATE conversations SET name = $2, updated_at = now() WHERE id = $1`, conversationID, newName, ); err != nil { writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht aktualisiert werden") return } s.emitSystemMessage( r.Context(), conversationID, user.ID, "group_renamed", fmt.Sprintf("%s hat die Gruppe in „%s“ umbenannt.", conversationActorName(user), newName), map[string]any{"name": newName}, ) changed = true } } if input.Image != nil { newImage := strings.TrimSpace(*input.Image) if len(newImage) > maxGroupImageBytes { writeError(w, http.StatusBadRequest, "Das Gruppenbild ist zu groß") return } if newImage != currentImage { if _, err := s.db.Exec( r.Context(), `UPDATE conversations SET channel_image = $2, updated_at = now() WHERE id = $1`, conversationID, newImage, ); err != nil { writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht aktualisiert werden") return } s.emitSystemMessage( r.Context(), conversationID, user.ID, "group_image_changed", fmt.Sprintf("%s hat das Gruppenbild geändert.", conversationActorName(user)), nil, ) changed = true } } if input.IsPublic != nil && *input.IsPublic != currentIsPublic { if _, err := s.db.Exec( r.Context(), `UPDATE conversations SET is_public = $2, updated_at = now() WHERE id = $1`, conversationID, *input.IsPublic, ); err != nil { writeError(w, http.StatusInternalServerError, "Chat konnte nicht aktualisiert werden") return } event := "conversation_private" target := "den Chat" if conversationType == "group" { target = "die Gruppe" } body := fmt.Sprintf("%s hat %s auf privat gestellt.", conversationActorName(user), target) if *input.IsPublic { event = "conversation_public" body = fmt.Sprintf("%s hat %s öffentlich gemacht.", conversationActorName(user), target) } s.emitSystemMessage( r.Context(), conversationID, user.ID, event, body, map[string]any{"isPublic": *input.IsPublic}, ) changed = true } if changed { s.publishConversationUpdate(r.Context(), conversationID) } conversation, err := s.getChatConversation(r.Context(), conversationID, user.ID, user.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) handleAddGroupMembers(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.canManageGroupConversation(r.Context(), conversationID, user) { writeError(w, http.StatusForbidden, "Nur der Ersteller oder ein Administrator darf die Gruppe verwalten") return } var input addGroupMembersRequest if err := readJSON(r, &input); err != nil { writeError(w, http.StatusBadRequest, "Ungültige Anfrage") return } added := false seen := map[string]struct{}{} for _, rawID := range input.UserIDs { id := strings.TrimSpace(rawID) if id == "" { continue } if _, dup := seen[id]; dup { continue } seen[id] = struct{}{} commandTag, err := s.db.Exec( r.Context(), ` INSERT INTO conversation_members (conversation_id, user_id) SELECT $1, id FROM users WHERE id = $2 ON CONFLICT (conversation_id, user_id) DO NOTHING `, conversationID, id, ) if err != nil { writeError(w, http.StatusInternalServerError, "Mitglied konnte nicht hinzugefügt werden") return } if commandTag.RowsAffected() == 0 { continue } s.emitSystemMessage( r.Context(), conversationID, user.ID, "member_added", fmt.Sprintf("%s hat %s hinzugefügt.", conversationActorName(user), s.userDisplayName(r.Context(), id)), map[string]any{"targetUserId": id}, ) added = true } if added { s.publishConversationUpdate(r.Context(), conversationID) } conversation, err := s.getChatConversation(r.Context(), conversationID, user.ID, user.ShowLastSeen) if err != nil { writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht geladen werden") return } writeJSON(w, http.StatusOK, map[string]any{"conversation": conversation}) } func (s *Server) handleRemoveGroupMember(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")) targetID := strings.TrimSpace(r.PathValue("userId")) var convType, ownerID, teamID string err := s.db.QueryRow( r.Context(), ` SELECT type, COALESCE(created_by::TEXT, ''), COALESCE(team_id::TEXT, '') FROM conversations WHERE id = $1 `, conversationID, ).Scan(&convType, &ownerID, &teamID) if errors.Is(err, pgx.ErrNoRows) || convType != "group" || teamID != "" { writeError(w, http.StatusNotFound, "Gruppe wurde nicht gefunden") return } if err != nil { writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht geladen werden") return } isManager := canManageGroup(user, ownerID) isSelf := targetID == user.ID if !isManager && !isSelf { writeError(w, http.StatusForbidden, "Nur der Ersteller oder ein Administrator darf Mitglieder entfernen") return } if targetID == ownerID { writeError(w, http.StatusBadRequest, "Der Ersteller kann die Gruppe nicht verlassen") return } commandTag, err := s.db.Exec( r.Context(), `DELETE FROM conversation_members WHERE conversation_id = $1 AND user_id = $2`, conversationID, targetID, ) if err != nil { writeError(w, http.StatusInternalServerError, "Mitglied konnte nicht entfernt werden") return } if commandTag.RowsAffected() == 0 { writeError(w, http.StatusNotFound, "Mitglied wurde nicht gefunden") return } if isSelf { s.emitSystemMessage( r.Context(), conversationID, targetID, "member_left", fmt.Sprintf("%s hat die Gruppe verlassen.", conversationActorName(user)), nil, ) } else { s.emitSystemMessage( r.Context(), conversationID, user.ID, "member_removed", fmt.Sprintf("%s hat %s entfernt.", conversationActorName(user), s.userDisplayName(r.Context(), targetID)), map[string]any{"targetUserId": targetID}, ) } // Verbleibende Mitglieder erhalten die aktualisierte Mitgliederliste, der // entfernte Nutzer erhält einen Hinweis, den Chat zu entfernen. s.publishConversationUpdate(r.Context(), conversationID) chatSockets.publish(targetID, chatSocketEvent{ Type: "conversation.removed", ConversationID: conversationID, }) writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) } func (s *Server) handleDeleteGroup(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 conversationType, ownerID, teamID string err := s.db.QueryRow( r.Context(), ` SELECT type, COALESCE(created_by::TEXT, ''), COALESCE(team_id::TEXT, '') FROM conversations WHERE id = $1 `, conversationID, ).Scan(&conversationType, &ownerID, &teamID) if errors.Is(err, pgx.ErrNoRows) || conversationType != "group" || teamID != "" { writeError(w, http.StatusNotFound, "Gruppe wurde nicht gefunden") return } if err != nil { writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht geladen werden") return } if !canManageGroup(user, ownerID) { writeError(w, http.StatusForbidden, "Nur der Ersteller oder ein Administrator darf die Gruppe löschen") return } memberIDs, err := s.chatConversationMemberIDs(r.Context(), conversationID) if err != nil { writeError(w, http.StatusInternalServerError, "Gruppenmitglieder konnten nicht geladen werden") return } tx, err := s.db.Begin(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht gelöscht werden") return } defer tx.Rollback(r.Context()) if _, err := tx.Exec( r.Context(), `DELETE FROM notifications WHERE entity_type = 'chat' AND entity_id = $1`, conversationID, ); err != nil { writeError(w, http.StatusInternalServerError, "Chat-Benachrichtigungen konnten nicht gelöscht werden") return } commandTag, err := tx.Exec( r.Context(), `DELETE FROM conversations WHERE id = $1 AND type = 'group' AND team_id IS NULL`, conversationID, ) if err != nil { writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht gelöscht werden") return } if commandTag.RowsAffected() == 0 { writeError(w, http.StatusNotFound, "Gruppe wurde nicht gefunden") return } if err := tx.Commit(r.Context()); err != nil { writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht gelöscht werden") return } for _, memberID := range memberIDs { chatSockets.publish(memberID, chatSocketEvent{ Type: "conversation.removed", ConversationID: conversationID, }) } writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) } func (s *Server) handleUpdateDisappearing(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 updateDisappearingRequest if err := readJSON(r, &input); err != nil { writeError(w, http.StatusBadRequest, "Ungültige Anfrage") return } if !allowedDisappearingSeconds[input.Seconds] { writeError(w, http.StatusBadRequest, "Ungültiger Zeitraum") return } var convType, ownerID, teamID string err := s.db.QueryRow( r.Context(), ` SELECT type, COALESCE(created_by::TEXT, ''), COALESCE(team_id::TEXT, '') FROM conversations WHERE id = $1 `, conversationID, ).Scan(&convType, &ownerID, &teamID) if errors.Is(err, pgx.ErrNoRows) { writeError(w, http.StatusNotFound, "Chat wurde nicht gefunden") return } if err != nil { writeError(w, http.StatusInternalServerError, "Chat konnte nicht geladen werden") return } switch convType { case "direct": if !s.canAccessConversation(r.Context(), conversationID, user.ID) { writeError(w, http.StatusForbidden, "Kein Zugriff auf diesen Chat") return } case "group": if teamID != "" { if !canManageTeamGroup(user) { writeError(w, http.StatusForbidden, "Keine Berechtigung zum Ändern selbstlöschender Nachrichten im Team-Gruppenchat") return } } else if !canManageGroup(user, ownerID) { writeError(w, http.StatusForbidden, "Nur der Ersteller oder ein Administrator darf das Selbstlöschen ändern") return } case "channel": if !userHasRight(user, "admin") { writeError(w, http.StatusForbidden, "Nur Administratoren dürfen das Selbstlöschen für Channels ändern") return } default: writeError(w, http.StatusBadRequest, "Selbstlöschen wird für diesen Chat nicht unterstützt") return } tx, err := s.db.Begin(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "Einstellung konnte nicht gespeichert werden") return } defer tx.Rollback(r.Context()) if _, err := tx.Exec( r.Context(), `UPDATE conversations SET disappearing_seconds = $2, updated_at = now() WHERE id = $1`, conversationID, input.Seconds, ); err != nil { writeError(w, http.StatusInternalServerError, "Einstellung konnte nicht gespeichert werden") return } if err := tx.Commit(r.Context()); err != nil { writeError(w, http.StatusInternalServerError, "Einstellung konnte nicht gespeichert werden") return } if input.Seconds == 0 { s.emitSystemMessage( r.Context(), conversationID, user.ID, "self_delete_disabled", fmt.Sprintf("%s hat selbstlöschende Nachrichten deaktiviert.", conversationActorName(user)), map[string]any{"seconds": 0}, ) } else { s.emitSystemMessage( r.Context(), conversationID, user.ID, "self_delete_enabled", fmt.Sprintf( "%s hat selbstlöschende Nachrichten aktiviert (%s).", conversationActorName(user), disappearingDurationLabel(input.Seconds), ), map[string]any{"seconds": input.Seconds}, ) } s.publishConversationUpdate(r.Context(), conversationID) s.publishChatMessagesReload(r.Context(), conversationID) conversation, err := s.getChatConversation(r.Context(), conversationID, user.ID, user.ShowLastSeen) if err != nil { writeError(w, http.StatusInternalServerError, "Chat konnte nicht geladen werden") return } writeJSON(w, http.StatusOK, map[string]any{"conversation": conversation}) }