package main import ( "context" "fmt" "net/http" "strings" "time" "github.com/jackc/pgx/v5" ) type AdminGroupChat struct { ID string `json:"id"` Name string `json:"name"` Image string `json:"image"` OwnerID string `json:"ownerId"` TeamID string `json:"teamId"` UserIDs []string `json:"userIds"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } type saveAdminGroupChatRequest struct { Name string `json:"name"` Image string `json:"image"` UserIDs []string `json:"userIds"` } func (s *Server) handleAdminListGroupChats(w http.ResponseWriter, r *http.Request) { rows, err := s.db.Query( r.Context(), ` SELECT c.id::TEXT, c.name, c.channel_image, COALESCE(c.created_by::TEXT, ''), COALESCE(c.team_id::TEXT, ''), COALESCE( array_agg(cm.user_id::TEXT ORDER BY cm.created_at) FILTER (WHERE cm.user_id IS NOT NULL), '{}'::TEXT[] ), c.created_at, c.updated_at FROM conversations c LEFT JOIN conversation_members cm ON cm.conversation_id = c.id WHERE c.type = 'group' GROUP BY c.id ORDER BY (c.team_id IS NOT NULL), lower(c.name), c.created_at `, ) if err != nil { writeError(w, http.StatusInternalServerError, "Gruppenchats konnten nicht geladen werden") return } defer rows.Close() groups := []AdminGroupChat{} for rows.Next() { var group AdminGroupChat if err := rows.Scan( &group.ID, &group.Name, &group.Image, &group.OwnerID, &group.TeamID, &group.UserIDs, &group.CreatedAt, &group.UpdatedAt, ); err != nil { writeError(w, http.StatusInternalServerError, "Gruppenchats konnten nicht gelesen werden") return } groups = append(groups, group) } if err := rows.Err(); err != nil { writeError(w, http.StatusInternalServerError, "Gruppenchats konnten nicht vollständig geladen werden") return } rows.Close() 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{ "groups": groups, "users": users, }) } func (s *Server) handleAdminUpdateGroupChat(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 conversationID == "" { writeError(w, http.StatusBadRequest, "Gruppenchat-ID fehlt") return } var input saveAdminGroupChatRequest if err := readJSON(r, &input); err != nil { writeError(w, http.StatusBadRequest, "Ungültige Anfrage") return } input.Name = strings.TrimSpace(input.Name) input.Image = strings.TrimSpace(input.Image) input.UserIDs = cleanStringList(input.UserIDs) if input.Name == "" || len([]rune(input.Name)) > maxGroupNameLength { writeError(w, http.StatusBadRequest, "Gruppenname muss 1–80 Zeichen lang sein") return } if len(input.Image) > maxGroupImageBytes { writeError(w, http.StatusBadRequest, "Das Gruppenbild ist zu groß") return } tx, err := s.db.Begin(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "Gruppenchat konnte nicht aktualisiert werden") return } defer tx.Rollback(r.Context()) var currentName, currentImage, ownerID string if err := tx.QueryRow( r.Context(), ` SELECT name, channel_image, COALESCE(created_by::TEXT, '') FROM conversations WHERE id = $1 AND type = 'group' AND team_id IS NULL FOR UPDATE `, conversationID, ).Scan(¤tName, ¤tImage, &ownerID); err != nil { if err == pgx.ErrNoRows { writeError(w, http.StatusNotFound, "Gruppenchat wurde nicht gefunden") return } writeError(w, http.StatusInternalServerError, "Gruppenchat konnte nicht geladen werden") return } if ownerID != "" { input.UserIDs = cleanStringList(append(input.UserIDs, ownerID)) } if len(input.UserIDs) == 0 { writeError(w, http.StatusBadRequest, "Mindestens ein Gruppenmitglied ist erforderlich") return } oldMemberIDs, err := adminGroupMemberIDs(r.Context(), tx, conversationID) if err != nil { writeError(w, http.StatusInternalServerError, "Gruppenmitglieder konnten nicht geladen werden") return } if _, err := tx.Exec( r.Context(), ` UPDATE conversations SET name = $2, channel_image = $3, updated_at = now() WHERE id = $1 AND type = 'group' AND team_id IS NULL `, conversationID, input.Name, input.Image, ); err != nil { writeError(w, http.StatusInternalServerError, "Gruppenchat konnte nicht aktualisiert werden") return } if _, err := tx.Exec( r.Context(), ` DELETE FROM conversation_members WHERE conversation_id = $1 AND NOT (user_id::TEXT = ANY($2::TEXT[])) `, conversationID, input.UserIDs, ); err != nil { writeError(w, http.StatusInternalServerError, "Gruppenmitglieder konnten nicht gespeichert werden") return } if _, err := tx.Exec( r.Context(), ` INSERT INTO conversation_members (conversation_id, user_id) SELECT $1, id FROM users WHERE id::TEXT = ANY($2::TEXT[]) ON CONFLICT (conversation_id, user_id) DO NOTHING `, conversationID, input.UserIDs, ); err != nil { writeError(w, http.StatusInternalServerError, "Gruppenmitglieder konnten nicht gespeichert werden") return } newMemberIDs, err := adminGroupMemberIDs(r.Context(), tx, conversationID) if err != nil || len(newMemberIDs) == 0 { writeError(w, http.StatusBadRequest, "Mindestens ein gültiges Gruppenmitglied ist erforderlich") return } if err := tx.Commit(r.Context()); err != nil { writeError(w, http.StatusInternalServerError, "Gruppenchat konnte nicht gespeichert werden") return } changed := currentName != input.Name || currentImage != input.Image || !sameStringSet(oldMemberIDs, newMemberIDs) if changed { s.emitSystemMessage( r.Context(), conversationID, user.ID, "group_admin_updated", fmt.Sprintf("%s hat den Gruppenchat über die Administration aktualisiert.", conversationActorName(user)), nil, ) s.publishConversationUpdate(r.Context(), conversationID) } newMembers := make(map[string]struct{}, len(newMemberIDs)) for _, memberID := range newMemberIDs { newMembers[memberID] = struct{}{} } for _, memberID := range oldMemberIDs { if _, remains := newMembers[memberID]; remains { continue } chatSockets.publish(memberID, chatSocketEvent{ Type: "conversation.removed", ConversationID: conversationID, }) } writeJSON(w, http.StatusOK, map[string]any{"id": conversationID}) } func (s *Server) handleAdminDeleteGroupChat(w http.ResponseWriter, r *http.Request) { conversationID := strings.TrimSpace(r.PathValue("id")) if conversationID == "" { writeError(w, http.StatusBadRequest, "Gruppenchat-ID fehlt") 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, "Gruppenchat 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, "Gruppenchat konnte nicht gelöscht werden") return } if commandTag.RowsAffected() == 0 { writeError(w, http.StatusNotFound, "Gruppenchat wurde nicht gefunden") return } if err := tx.Commit(r.Context()); err != nil { writeError(w, http.StatusInternalServerError, "Gruppenchat 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}) } // handleAdminEmptyGroupChat löscht den gesamten Nachrichtenverlauf eines // Gruppenchats (inkl. Anhänge über ON DELETE CASCADE), behält den Chat aber. // Für Team-Gruppenchats ist dies die einzige zulässige Admin-Aktion. func (s *Server) handleAdminEmptyGroupChat(w http.ResponseWriter, r *http.Request) { conversationID := strings.TrimSpace(r.PathValue("id")) if conversationID == "" { writeError(w, http.StatusBadRequest, "Gruppenchat-ID fehlt") return } tx, err := s.db.Begin(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "Gruppenchat konnte nicht geleert werden") return } defer tx.Rollback(r.Context()) var exists bool if err := tx.QueryRow( r.Context(), `SELECT true FROM conversations WHERE id = $1 AND type = 'group'`, conversationID, ).Scan(&exists); err != nil { if err == pgx.ErrNoRows { writeError(w, http.StatusNotFound, "Gruppenchat wurde nicht gefunden") return } writeError(w, http.StatusInternalServerError, "Gruppenchat konnte nicht geladen werden") return } 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 } if _, err := tx.Exec( r.Context(), `DELETE FROM messages WHERE conversation_id = $1`, conversationID, ); err != nil { writeError(w, http.StatusInternalServerError, "Nachrichten konnten nicht gelöscht werden") return } if _, err := tx.Exec( r.Context(), `UPDATE conversations SET last_message_at = NULL, updated_at = now() WHERE id = $1`, conversationID, ); err != nil { writeError(w, http.StatusInternalServerError, "Gruppenchat konnte nicht aktualisiert werden") return } if err := tx.Commit(r.Context()); err != nil { writeError(w, http.StatusInternalServerError, "Gruppenchat konnte nicht geleert werden") return } s.publishChatMessagesReload(r.Context(), conversationID) s.publishConversationUpdate(r.Context(), conversationID) writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) } func adminGroupMemberIDs(ctx context.Context, tx pgx.Tx, conversationID string) ([]string, error) { rows, err := tx.Query( ctx, ` SELECT user_id::TEXT FROM conversation_members WHERE conversation_id = $1 ORDER BY created_at, 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 sameStringSet(left []string, right []string) bool { if len(left) != len(right) { return false } values := make(map[string]struct{}, len(left)) for _, value := range left { values[value] = struct{}{} } for _, value := range right { if _, exists := values[value]; !exists { return false } } return true }