432 lines
11 KiB
Go
432 lines
11 KiB
Go
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
|
||
}
|