841 lines
22 KiB
Go
841 lines
22 KiB
Go
// 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})
|
||
}
|