teg/backend/admin_group_chats.go
2026-07-03 16:56:20 +02:00

440 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"`
IsPublic bool `json:"isPublic"`
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"`
IsPublic bool `json:"isPublic"`
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, ''),
c.is_public,
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.IsPublic,
&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 180 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
var currentIsPublic bool
if err := tx.QueryRow(
r.Context(),
`
SELECT name, channel_image, COALESCE(created_by::TEXT, ''), is_public
FROM conversations
WHERE id = $1
AND type = 'group'
AND team_id IS NULL
FOR UPDATE
`,
conversationID,
).Scan(&currentName, &currentImage, &ownerID, &currentIsPublic); 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,
is_public = $4,
updated_at = now()
WHERE id = $1
AND type = 'group'
AND team_id IS NULL
`,
conversationID,
input.Name,
input.Image,
input.IsPublic,
); 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 ||
currentIsPublic != input.IsPublic ||
!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
}