diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..e2e620a --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "PowerShell(Set-Location \"c:\\\\Users\\\\Rother\\\\fork\\\\teg\\\\backend\"; go vet ./... 2>&1 | Select-Object -First 20; \"VET: $LASTEXITCODE\"; gofmt -w chat.go chat_groups.go chat_websocket.go chat_unread.go chat_search.go main.go routes.go setup\\\\main.go; gofmt -l chat.go chat_groups.go chat_websocket.go chat_unread.go chat_search.go; \"fmt done\")", + "PowerShell(Set-Location \"c:\\\\Users\\\\Rother\\\\fork\\\\teg\\\\frontend\"; npx tsc --noEmit 2>&1 | Select-Object -First 40; \"TSC EXIT: $LASTEXITCODE\")", + "PowerShell(Set-Location \"c:\\\\Users\\\\Rother\\\\fork\\\\teg\\\\frontend\"; npx tsc --noEmit 2>&1 | Select-Object -First 30; \"TSC EXIT: $LASTEXITCODE\")", + "Bash(npx tsc *)", + "Bash(echo \"EXIT:$?\")" + ] + } +} diff --git a/.gitignore b/.gitignore index d94969b..8b91521 100644 --- a/.gitignore +++ b/.gitignore @@ -74,4 +74,5 @@ Thumbs.db $RECYCLE.BIN/ # Windows shortcuts -*.lnk \ No newline at end of file +*.lnk +.gocache diff --git a/backend/.env b/backend/.env index 8165ec3..7fd8c8e 100644 --- a/backend/.env +++ b/backend/.env @@ -1,7 +1,7 @@ PORT=8080 DATABASE_URL=postgres://postgres:Timmy0104199%3F@localhost:5432/teg?sslmode=disable JWT_SECRET=tegvideo7010! -FRONTEND_ORIGIN=http://localhost:5173 +FRONTEND_ORIGIN=https://localhost:5173 ADDRESS_SEARCH_PROVIDER=photon ADDRESS_SEARCH_URL=https://photon.komoot.io/api ADDRESS_SEARCH_LANGUAGE=de @@ -12,9 +12,13 @@ ADDRESS_SEARCH_USER_AGENT=TEG-App/1.0 ADMIN_USERNAME=admin ADMIN_DISPLAY_NAME=Administrator ADMIN_EMAIL=admin@tegdssd.de -ADMIN_UNIT=Administration +ADMIN_UNIT=TEG ADMIN_GROUP=admin ADMIN_RIGHTS=admin,users:read,users:write ADMIN_TEAM_NAME=Administration ADMIN_TEAM_INITIAL=A -ADMIN_TEAM_HREF=#administration \ No newline at end of file +ADMIN_TEAM_HREF=#administration + +VAPID_PUBLIC_KEY=BEFezMtT5PwJBOm9VIpP_1Y9Pwn8XAZfOHY2aQI_D9MaWsD3wn87OEdOMLlOa87fmDeB5DMVlpVrRnwBZNbXZ4E +VAPID_PRIVATE_KEY=3QSvqwejg62ttALunCiib3CXGos2ylvDCDoRGl_OPrQ +VAPID_SUBSCRIBER=mailto:admin@tegdssd.de diff --git a/backend/admin.go b/backend/admin.go index 028c3ef..246b82c 100644 --- a/backend/admin.go +++ b/backend/admin.go @@ -15,12 +15,13 @@ import ( ) type AdminTeam struct { - ID string `json:"id"` - Name string `json:"name"` - Initial string `json:"initial"` - Href string `json:"href"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID string `json:"id"` + Name string `json:"name"` + Initial string `json:"initial"` + Href string `json:"href"` + ConversationID string `json:"conversationId"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } type AdminUser struct { @@ -472,16 +473,89 @@ func replaceUserTeams(ctx context.Context, tx pgx.Tx, userID string, teamIDs []s } } + if _, err := tx.Exec( + ctx, + ` + INSERT INTO conversations (type, name, team_id) + SELECT 'group', t.name, t.id + FROM teams t + JOIN user_teams ut + ON ut.team_id = t.id + AND ut.user_id = $1 + ON CONFLICT (team_id) WHERE team_id IS NOT NULL + DO UPDATE SET + type = 'group', + name = EXCLUDED.name, + created_by = NULL, + updated_at = now() + `, + userID, + ); err != nil { + return err + } + + if _, err := tx.Exec( + ctx, + ` + DELETE FROM conversation_members cm + USING conversations c + WHERE cm.conversation_id = c.id + AND cm.user_id = $1 + AND c.team_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM user_teams ut + WHERE ut.user_id = cm.user_id + AND ut.team_id = c.team_id + ) + `, + userID, + ); err != nil { + return err + } + + if _, err := tx.Exec( + ctx, + ` + INSERT INTO conversation_members (conversation_id, user_id) + SELECT c.id, $1 + FROM conversations c + JOIN user_teams ut + ON ut.team_id = c.team_id + AND ut.user_id = $1 + WHERE c.team_id IS NOT NULL + ON CONFLICT (conversation_id, user_id) DO NOTHING + `, + userID, + ); err != nil { + return err + } + return nil } func (s *Server) handleAdminListTeams(w http.ResponseWriter, r *http.Request) { + if err := s.ensureTeamGroupConversations(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "Team-Gruppenchats konnten nicht vorbereitet werden") + return + } + rows, err := s.db.Query( r.Context(), ` - SELECT id::TEXT, name, initial, href, created_at, updated_at - FROM teams - ORDER BY name ASC + SELECT + t.id::TEXT, + t.name, + t.initial, + t.href, + COALESCE(c.id::TEXT, ''), + t.created_at, + t.updated_at + FROM teams t + LEFT JOIN conversations c + ON c.team_id = t.id + AND c.type = 'group' + ORDER BY t.name ASC `, ) @@ -501,6 +575,7 @@ func (s *Server) handleAdminListTeams(w http.ResponseWriter, r *http.Request) { &team.Name, &team.Initial, &team.Href, + &team.ConversationID, &team.CreatedAt, &team.UpdatedAt, ); err != nil { @@ -531,24 +606,53 @@ func (s *Server) handleAdminCreateTeam(w http.ResponseWriter, r *http.Request) { return } - _, err := s.db.Exec( - r.Context(), - ` - INSERT INTO teams (name, initial, href) - VALUES ($1, $2, $3) - `, - input.Name, - input.Initial, - input.Href, - ) - + tx, err := s.db.Begin(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "Team konnte nicht erstellt werden") return } + defer tx.Rollback(r.Context()) + + var teamID string + if err := tx.QueryRow( + r.Context(), + ` + INSERT INTO teams (name, initial, href) + VALUES ($1, $2, $3) + RETURNING id::TEXT + `, + input.Name, + input.Initial, + input.Href, + ).Scan(&teamID); err != nil { + writeError(w, http.StatusInternalServerError, "Team konnte nicht erstellt werden") + return + } + + var conversationID string + if err := tx.QueryRow( + r.Context(), + ` + INSERT INTO conversations (type, name, team_id) + VALUES ('group', $1, $2) + RETURNING id::TEXT + `, + input.Name, + teamID, + ).Scan(&conversationID); err != nil { + writeError(w, http.StatusInternalServerError, "Team-Gruppenchat konnte nicht erstellt werden") + return + } + + if err := tx.Commit(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "Team konnte nicht gespeichert werden") + return + } writeJSON(w, http.StatusCreated, map[string]any{ - "ok": true, + "ok": true, + "id": teamID, + "conversationId": conversationID, }) } @@ -574,7 +678,14 @@ func (s *Server) handleAdminUpdateTeam(w http.ResponseWriter, r *http.Request) { return } - _, err := s.db.Exec( + tx, err := s.db.Begin(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "Team konnte nicht aktualisiert werden") + return + } + defer tx.Rollback(r.Context()) + + commandTag, err := tx.Exec( r.Context(), ` UPDATE teams @@ -589,13 +700,45 @@ func (s *Server) handleAdminUpdateTeam(w http.ResponseWriter, r *http.Request) { input.Initial, input.Href, ) - if err != nil { writeError(w, http.StatusInternalServerError, "Team konnte nicht aktualisiert werden") return } + if commandTag.RowsAffected() == 0 { + writeError(w, http.StatusNotFound, "Team wurde nicht gefunden") + return + } + + var conversationID string + if err := tx.QueryRow( + r.Context(), + ` + INSERT INTO conversations (type, name, team_id) + VALUES ('group', $1, $2) + ON CONFLICT (team_id) WHERE team_id IS NOT NULL + DO UPDATE SET + type = 'group', + name = EXCLUDED.name, + created_by = NULL, + updated_at = now() + RETURNING id::TEXT + `, + input.Name, + teamID, + ).Scan(&conversationID); err != nil { + writeError(w, http.StatusInternalServerError, "Team-Gruppenchat konnte nicht aktualisiert werden") + return + } + + if err := tx.Commit(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "Team konnte nicht gespeichert werden") + return + } + + s.publishConversationUpdate(r.Context(), conversationID) writeJSON(w, http.StatusOK, map[string]any{ - "ok": true, + "ok": true, + "conversationId": conversationID, }) } diff --git a/backend/admin_group_chats.go b/backend/admin_group_chats.go new file mode 100644 index 0000000..3a55b3b --- /dev/null +++ b/backend/admin_group_chats.go @@ -0,0 +1,360 @@ +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"` + 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( + 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' + AND c.team_id IS NULL + GROUP BY c.id + ORDER BY 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.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}) +} + +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 +} diff --git a/backend/calendar_settings.go b/backend/calendar_settings.go new file mode 100644 index 0000000..cdd0746 --- /dev/null +++ b/backend/calendar_settings.go @@ -0,0 +1,217 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "regexp" + "time" +) + +type CalendarWorkingHoursDay struct { + Enabled bool `json:"enabled"` + Start string `json:"start"` + End string `json:"end"` +} + +type CalendarCoreWorkingHours struct { + Monday CalendarWorkingHoursDay `json:"monday"` + Tuesday CalendarWorkingHoursDay `json:"tuesday"` + Wednesday CalendarWorkingHoursDay `json:"wednesday"` + Thursday CalendarWorkingHoursDay `json:"thursday"` + Friday CalendarWorkingHoursDay `json:"friday"` + Saturday CalendarWorkingHoursDay `json:"saturday"` + Sunday CalendarWorkingHoursDay `json:"sunday"` +} + +type CalendarSettings struct { + CoreWorkingHours CalendarCoreWorkingHours `json:"coreWorkingHours"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` +} + +var calendarTimePattern = regexp.MustCompile(`^\d{2}:\d{2}$`) + +func defaultCalendarCoreWorkingHours() CalendarCoreWorkingHours { + workingDay := CalendarWorkingHoursDay{ + Enabled: true, + Start: "08:00", + End: "17:00", + } + dayOff := CalendarWorkingHoursDay{ + Enabled: false, + Start: "08:00", + End: "17:00", + } + + return CalendarCoreWorkingHours{ + Monday: workingDay, + Tuesday: workingDay, + Wednesday: workingDay, + Thursday: workingDay, + Friday: workingDay, + Saturday: dayOff, + Sunday: dayOff, + } +} + +func (s *Server) ensureCalendarSettingsTable(r *http.Request) error { + defaultHours, err := json.Marshal(defaultCalendarCoreWorkingHours()) + if err != nil { + return err + } + + _, err = s.db.Exec( + r.Context(), + ` + CREATE TABLE IF NOT EXISTS calendar_settings ( + id SMALLINT PRIMARY KEY CHECK (id = 1), + core_working_hours JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `, + ) + if err != nil { + return err + } + + _, err = s.db.Exec( + r.Context(), + ` + INSERT INTO calendar_settings (id, core_working_hours) + VALUES (1, $1::JSONB) + ON CONFLICT (id) DO NOTHING + `, + string(defaultHours), + ) + return err +} + +func (s *Server) loadCalendarSettings(r *http.Request) (CalendarSettings, error) { + if err := s.ensureCalendarSettingsTable(r); err != nil { + return CalendarSettings{}, err + } + + var rawHours []byte + var settings CalendarSettings + var updatedAt time.Time + + err := s.db.QueryRow( + r.Context(), + ` + SELECT core_working_hours, updated_at + FROM calendar_settings + WHERE id = 1 + `, + ).Scan(&rawHours, &updatedAt) + if err != nil { + return CalendarSettings{}, err + } + + if err := json.Unmarshal(rawHours, &settings.CoreWorkingHours); err != nil { + return CalendarSettings{}, err + } + + settings.UpdatedAt = &updatedAt + return settings, nil +} + +func validateCalendarWorkingHours(hours CalendarCoreWorkingHours) error { + days := []struct { + name string + hours CalendarWorkingHoursDay + }{ + {name: "Montag", hours: hours.Monday}, + {name: "Dienstag", hours: hours.Tuesday}, + {name: "Mittwoch", hours: hours.Wednesday}, + {name: "Donnerstag", hours: hours.Thursday}, + {name: "Freitag", hours: hours.Friday}, + {name: "Samstag", hours: hours.Saturday}, + {name: "Sonntag", hours: hours.Sunday}, + } + + for _, day := range days { + if !calendarTimePattern.MatchString(day.hours.Start) || + !calendarTimePattern.MatchString(day.hours.End) { + return fmt.Errorf("%s: Beginn und Ende müssen im Format HH:MM angegeben werden", day.name) + } + + start, startErr := time.Parse("15:04", day.hours.Start) + end, endErr := time.Parse("15:04", day.hours.End) + if startErr != nil || endErr != nil { + return fmt.Errorf("%s: ungültige Uhrzeit", day.name) + } + + if day.hours.Enabled && !end.After(start) { + return fmt.Errorf("%s: das Ende muss nach dem Beginn liegen", day.name) + } + } + + return nil +} + +func (s *Server) handleGetCalendarSettings(w http.ResponseWriter, r *http.Request) { + settings, err := s.loadCalendarSettings(r) + if err != nil { + writeError(w, http.StatusInternalServerError, "Kalender-Einstellungen konnten nicht geladen werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "settings": settings, + }) +} + +func (s *Server) handleUpdateCalendarSettings(w http.ResponseWriter, r *http.Request) { + var payload struct { + CoreWorkingHours CalendarCoreWorkingHours `json:"coreWorkingHours"` + } + + if err := readJSON(r, &payload); err != nil { + writeError(w, http.StatusBadRequest, "Ungültige Anfrage") + return + } + + if err := validateCalendarWorkingHours(payload.CoreWorkingHours); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + if err := s.ensureCalendarSettingsTable(r); err != nil { + writeError(w, http.StatusInternalServerError, "Kalender-Einstellungen konnten nicht vorbereitet werden") + return + } + + rawHours, err := json.Marshal(payload.CoreWorkingHours) + if err != nil { + writeError(w, http.StatusBadRequest, "Kalender-Einstellungen sind ungültig") + return + } + + _, err = s.db.Exec( + r.Context(), + ` + INSERT INTO calendar_settings (id, core_working_hours) + VALUES (1, $1::JSONB) + ON CONFLICT (id) DO UPDATE SET + core_working_hours = EXCLUDED.core_working_hours, + updated_at = now() + `, + string(rawHours), + ) + if err != nil { + writeError(w, http.StatusInternalServerError, "Kalender-Einstellungen konnten nicht gespeichert werden") + return + } + + settings, err := s.loadCalendarSettings(r) + if err != nil { + writeError(w, http.StatusInternalServerError, "Kalender-Einstellungen wurden gespeichert, konnten aber nicht neu geladen werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "message": "Kalender-Einstellungen gespeichert", + "settings": settings, + }) +} diff --git a/backend/channels.go b/backend/channels.go index 33659fd..553a2c8 100644 --- a/backend/channels.go +++ b/backend/channels.go @@ -579,8 +579,15 @@ func (s *Server) createChannelMessage( err = tx.QueryRow( ctx, ` - INSERT INTO messages (conversation_id, body) - SELECT id, $2 + INSERT INTO messages (conversation_id, body, expires_at) + SELECT + id, + $2, + CASE + WHEN disappearing_seconds > 0 + THEN now() + make_interval(secs => disappearing_seconds) + ELSE NULL + END FROM conversations WHERE id = $1 AND type = 'channel' diff --git a/backend/chat.go b/backend/chat.go index f436e2b..3f6ab6b 100644 --- a/backend/chat.go +++ b/backend/chat.go @@ -3,6 +3,7 @@ package main import ( "context" "database/sql" + "encoding/json" "errors" "math" "net/http" @@ -30,9 +31,12 @@ type ChatUser struct { type ChatMessage struct { ID string `json:"id"` ConversationID string `json:"conversationId"` + Type string `json:"type"` Body string `json:"body"` + SystemEvent json.RawMessage `json:"systemEvent,omitempty"` CreatedAt time.Time `json:"createdAt"` EditedAt *time.Time `json:"editedAt"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` Sender ChatUser `json:"sender"` Attachments []ChatAttachment `json:"attachments"` ReplyTo *ChatMessageReference `json:"replyTo"` @@ -73,17 +77,19 @@ type ChatMessageReference struct { } type ChatConversation struct { - ID string `json:"id"` - Type string `json:"type"` - Name string `json:"name"` - Image string `json:"image"` - TeamID string `json:"teamId"` - Participants []ChatUser `json:"participants"` - LastMessage *ChatMessage `json:"lastMessage"` - LastMessageAt *time.Time `json:"lastMessageAt"` - Muted bool `json:"muted"` - UnreadCount int `json:"unreadCount"` - CreatedAt time.Time `json:"createdAt"` + ID string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Image string `json:"image"` + TeamID string `json:"teamId"` + OwnerID string `json:"ownerId"` + DisappearingSeconds int `json:"disappearingSeconds"` + Participants []ChatUser `json:"participants"` + LastMessage *ChatMessage `json:"lastMessage"` + LastMessageAt *time.Time `json:"lastMessageAt"` + Muted bool `json:"muted"` + UnreadCount int `json:"unreadCount"` + CreatedAt time.Time `json:"createdAt"` } type createDirectConversationRequest struct { @@ -199,30 +205,40 @@ func (s *Server) handleListChatUsers(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"users": users}) } -func (s *Server) ensureTeamConversations(ctx context.Context, userID string) error { +func (s *Server) ensureTeamGroupConversations(ctx context.Context) error { _, err := s.db.Exec( ctx, ` - WITH user_team_conversations AS ( + WITH team_groups AS ( INSERT INTO conversations (type, name, team_id, created_by) - SELECT 'team', t.name, t.id, $1 + SELECT 'group', t.name, t.id, NULL FROM teams t - JOIN user_teams current_membership - ON current_membership.team_id = t.id - AND current_membership.user_id = $1 ON CONFLICT (team_id) WHERE team_id IS NOT NULL DO UPDATE SET + type = 'group', name = EXCLUDED.name, + created_by = NULL, updated_at = now() RETURNING id, team_id + ), + removed_members AS ( + DELETE FROM conversation_members cm + USING conversations c + WHERE cm.conversation_id = c.id + AND c.team_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM user_teams ut + WHERE ut.team_id = c.team_id + AND ut.user_id = cm.user_id + ) ) INSERT INTO conversation_members (conversation_id, user_id) - SELECT utc.id, ut.user_id - FROM user_team_conversations utc - JOIN user_teams ut ON ut.team_id = utc.team_id + SELECT tg.id, ut.user_id + FROM team_groups tg + JOIN user_teams ut ON ut.team_id = tg.team_id ON CONFLICT (conversation_id, user_id) DO NOTHING `, - userID, ) return err @@ -235,8 +251,8 @@ func (s *Server) handleListChatConversations(w http.ResponseWriter, r *http.Requ return } - if err := s.ensureTeamConversations(r.Context(), user.ID); err != nil { - writeError(w, http.StatusInternalServerError, "Team-Chats konnten nicht vorbereitet werden") + if err := s.ensureTeamGroupConversations(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "Team-Gruppenchats konnten nicht vorbereitet werden") return } if err := s.ensureAllUserChannels(r.Context(), user.ID); err != nil { @@ -269,29 +285,21 @@ func (s *Server) handleListChatConversations(w http.ResponseWriter, r *http.Requ WHERE unread_message.conversation_id = c.id AND unread_message.deleted_at IS NULL AND unread_message.sender_id IS DISTINCT FROM $1::UUID + AND (unread_message.expires_at IS NULL OR unread_message.expires_at > now()) AND unread_message.created_at > COALESCE( current_membership.last_read_at, '-infinity'::TIMESTAMPTZ ) ), 0), - c.created_at + c.created_at, + COALESCE(c.created_by::TEXT, ''), + c.disappearing_seconds FROM conversations c - WHERE ( - c.type = 'team' - AND EXISTS ( - SELECT 1 - FROM user_teams ut - WHERE ut.team_id = c.team_id - AND ut.user_id = $1 - ) - ) OR ( - c.type <> 'team' - AND EXISTS ( - SELECT 1 - FROM conversation_members cm - WHERE cm.conversation_id = c.id - AND cm.user_id = $1 - ) + WHERE EXISTS ( + SELECT 1 + FROM conversation_members cm + WHERE cm.conversation_id = c.id + AND cm.user_id = $1 ) ORDER BY COALESCE(c.last_message_at, c.created_at) DESC `, @@ -316,6 +324,8 @@ func (s *Server) handleListChatConversations(w http.ResponseWriter, r *http.Requ &conversation.Muted, &conversation.UnreadCount, &conversation.CreatedAt, + &conversation.OwnerID, + &conversation.DisappearingSeconds, ); err != nil { writeError(w, http.StatusInternalServerError, "Chats konnten nicht gelesen werden") return @@ -498,12 +508,16 @@ func (s *Server) handleListChatMessages(w http.ResponseWriter, r *http.Request) ELSE u.last_seen_at END ELSE NULL - END + END, + m.message_type, + m.system_event, + m.expires_at FROM messages m JOIN conversations c ON c.id = m.conversation_id LEFT JOIN users u ON u.id = m.sender_id WHERE m.conversation_id = $1 AND m.deleted_at IS NULL + AND (m.expires_at IS NULL OR m.expires_at > now()) ORDER BY m.created_at ASC, m.id ASC LIMIT 200 `, @@ -686,18 +700,25 @@ func (s *Server) createChatMessage( forwarded_from_message_id, location_lat, location_lng, - location_label + location_label, + expires_at ) - VALUES ( - $1, + SELECT + c.id, $2, $3, NULLIF($4, '')::UUID, NULLIF($5, '')::UUID, $6, $7, - $8 - ) + $8, + CASE + WHEN c.disappearing_seconds > 0 + THEN now() + make_interval(secs => c.disappearing_seconds) + ELSE NULL + END + FROM conversations c + WHERE c.id = $1 RETURNING id::TEXT `, conversationID, @@ -835,25 +856,11 @@ func (s *Server) canAccessConversation(ctx context.Context, conversationID strin SELECT 1 FROM conversations c WHERE c.id = $1 - AND ( - ( - c.type = 'team' - AND EXISTS ( - SELECT 1 - FROM user_teams ut - WHERE ut.team_id = c.team_id - AND ut.user_id = $2 - ) - ) - OR ( - c.type <> 'team' - AND EXISTS ( - SELECT 1 - FROM conversation_members cm - WHERE cm.conversation_id = c.id - AND cm.user_id = $2 - ) - ) + AND EXISTS ( + SELECT 1 + FROM conversation_members cm + WHERE cm.conversation_id = c.id + AND cm.user_id = $2 ) ) `, @@ -923,26 +930,8 @@ func (s *Server) listChatParticipants(ctx context.Context, conversationID string ELSE NULL END FROM conversations c - JOIN users u ON ( - ( - c.type = 'team' - AND EXISTS ( - SELECT 1 - FROM user_teams ut - WHERE ut.team_id = c.team_id - AND ut.user_id = u.id - ) - ) - OR ( - c.type <> 'team' - AND EXISTS ( - SELECT 1 - FROM conversation_members cm - WHERE cm.conversation_id = c.id - AND cm.user_id = u.id - ) - ) - ) + JOIN conversation_members cm ON cm.conversation_id = c.id + JOIN users u ON u.id = cm.user_id WHERE c.id = $1 ORDER BY lower(COALESCE(NULLIF(u.display_name, ''), u.username, u.email)) `, @@ -1014,12 +1003,15 @@ func (s *Server) getChatConversation(ctx context.Context, conversationID string, WHERE unread_message.conversation_id = c.id AND unread_message.deleted_at IS NULL AND unread_message.sender_id IS DISTINCT FROM $2::UUID + AND (unread_message.expires_at IS NULL OR unread_message.expires_at > now()) AND unread_message.created_at > COALESCE( current_membership.last_read_at, '-infinity'::TIMESTAMPTZ ) ), 0), - c.created_at + c.created_at, + COALESCE(c.created_by::TEXT, ''), + c.disappearing_seconds FROM conversations c WHERE c.id = $1 `, @@ -1035,6 +1027,8 @@ func (s *Server) getChatConversation(ctx context.Context, conversationID string, &conversation.Muted, &conversation.UnreadCount, &conversation.CreatedAt, + &conversation.OwnerID, + &conversation.DisappearingSeconds, ) if err != nil { return conversation, err @@ -1082,6 +1076,9 @@ func scanChatMessage(scanner chatMessageScanner) (ChatMessage, error) { &message.Sender.LastSeenAt, &message.Sender.AwaySince, &message.Sender.LastOnlineAt, + &message.Type, + &message.SystemEvent, + &message.ExpiresAt, ) message.Sender.Online = message.Sender.PresenceStatus == "online" return message, err @@ -1137,7 +1134,10 @@ func (s *Server) getChatMessage(ctx context.Context, messageID string) (ChatMess ELSE u.last_seen_at END ELSE NULL - END + END, + m.message_type, + m.system_event, + m.expires_at FROM messages m JOIN conversations c ON c.id = m.conversation_id LEFT JOIN users u ON u.id = m.sender_id @@ -1203,12 +1203,16 @@ func (s *Server) getLastChatMessage(ctx context.Context, conversationID string) ELSE u.last_seen_at END ELSE NULL - END + END, + m.message_type, + m.system_event, + m.expires_at FROM messages m JOIN conversations c ON c.id = m.conversation_id LEFT JOIN users u ON u.id = m.sender_id WHERE m.conversation_id = $1 AND m.deleted_at IS NULL + AND (m.expires_at IS NULL OR m.expires_at > now()) ORDER BY m.created_at DESC, m.id DESC LIMIT 1 `, diff --git a/backend/chat_groups.go b/backend/chat_groups.go new file mode 100644 index 0000000..dc3f5c7 --- /dev/null +++ b/backend/chat_groups.go @@ -0,0 +1,850 @@ +// 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"` +} + +type updateGroupRequest struct { + Name *string `json:"name"` + Image *string `json:"image"` +} + +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) { + if _, err := s.db.Exec( + ctx, + `UPDATE messages AS m + SET expires_at = m.created_at + make_interval(secs => c.disappearing_seconds) + FROM conversations AS c + WHERE m.conversation_id = c.id + AND c.disappearing_seconds > 0 + AND m.message_type = 'user' + AND m.deleted_at IS NULL + AND m.expires_at IS NULL`, + ); err != nil { + logError("Ablaufzeiten selbstlöschender Nachrichten konnten nicht ergänzt werden", err, nil) + return + } + + 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) + VALUES ('group', $1, $2, $3) + RETURNING id::TEXT + `, + name, + image, + user.ID, + ).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, "Gruppe 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 currentName, currentImage, ownerID, teamID string + if err := s.db.QueryRow( + r.Context(), + ` + SELECT + name, + channel_image, + COALESCE(created_by::TEXT, ''), + COALESCE(team_id::TEXT, '') + FROM conversations + WHERE id = $1 + AND type = 'group' + `, + conversationID, + ).Scan(¤tName, ¤tImage, &ownerID, &teamID); err != nil { + writeError(w, http.StatusNotFound, "Gruppe wurde nicht gefunden") + return + } + + 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 changed { + 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) 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 input.Seconds == 0 { + if _, err := tx.Exec( + r.Context(), + `UPDATE messages + SET expires_at = NULL + WHERE conversation_id = $1 + AND message_type = 'user' + AND deleted_at IS NULL`, + conversationID, + ); err != nil { + writeError(w, http.StatusInternalServerError, "Einstellung konnte nicht gespeichert werden") + return + } + } else { + if _, err := tx.Exec( + r.Context(), + `UPDATE messages + SET expires_at = created_at + make_interval(secs => $2) + WHERE conversation_id = $1 + AND message_type = 'user' + AND deleted_at IS NULL`, + conversationID, + input.Seconds, + ); err != nil { + writeError(w, http.StatusInternalServerError, "Einstellung konnte nicht gespeichert werden") + return + } + + if _, err := tx.Exec( + r.Context(), + `DELETE FROM messages + WHERE conversation_id = $1 + AND message_type = 'user' + AND deleted_at IS NULL + AND expires_at <= now()`, + conversationID, + ); 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}) +} diff --git a/backend/chat_search.go b/backend/chat_search.go index 78d17a7..fb23bc4 100644 --- a/backend/chat_search.go +++ b/backend/chat_search.go @@ -49,26 +49,14 @@ func (s *Server) handleSearchChatMessages(w http.ResponseWriter, r *http.Request JOIN conversations c ON c.id = m.conversation_id LEFT JOIN users u ON u.id = m.sender_id WHERE m.deleted_at IS NULL + AND m.message_type = 'user' + AND (m.expires_at IS NULL OR m.expires_at > now()) AND strpos(lower(m.body), lower($2)) > 0 - AND ( - ( - c.type = 'team' - AND EXISTS ( - SELECT 1 - FROM user_teams ut - WHERE ut.team_id = c.team_id - AND ut.user_id = $1 - ) - ) - OR ( - c.type <> 'team' - AND EXISTS ( - SELECT 1 - FROM conversation_members cm - WHERE cm.conversation_id = c.id - AND cm.user_id = $1 - ) - ) + AND EXISTS ( + SELECT 1 + FROM conversation_members cm + WHERE cm.conversation_id = c.id + AND cm.user_id = $1 ) ORDER BY m.conversation_id, m.created_at DESC, m.id DESC LIMIT 50 diff --git a/backend/chat_unread.go b/backend/chat_unread.go index 329b001..00c50a6 100644 --- a/backend/chat_unread.go +++ b/backend/chat_unread.go @@ -41,23 +41,10 @@ func (s *Server) getChatUnreadCount(ctx context.Context, userID string) (int, er ON cm.conversation_id = c.id AND cm.user_id = $1 WHERE m.deleted_at IS NULL + AND (m.expires_at IS NULL OR m.expires_at > now()) AND m.sender_id IS DISTINCT FROM $1::UUID AND m.created_at > COALESCE(cm.last_read_at, '-infinity'::TIMESTAMPTZ) - AND ( - ( - c.type = 'team' - AND EXISTS ( - SELECT 1 - FROM user_teams ut - WHERE ut.team_id = c.team_id - AND ut.user_id = $1 - ) - ) - OR ( - c.type <> 'team' - AND cm.user_id IS NOT NULL - ) - ) + AND cm.user_id IS NOT NULL `, userID, ).Scan(&unreadCount) @@ -71,8 +58,8 @@ func (s *Server) handleGetChatUnreadCount(w http.ResponseWriter, r *http.Request return } - if err := s.ensureTeamConversations(r.Context(), user.ID); err != nil { - writeError(w, http.StatusInternalServerError, "Team-Chats konnten nicht vorbereitet werden") + if err := s.ensureTeamGroupConversations(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "Team-Gruppenchats konnten nicht vorbereitet werden") return } if err := s.ensureAllUserChannels(r.Context(), user.ID); err != nil { diff --git a/backend/chat_websocket.go b/backend/chat_websocket.go index fe84b4b..9883860 100644 --- a/backend/chat_websocket.go +++ b/backend/chat_websocket.go @@ -25,14 +25,15 @@ type chatSocketCommand struct { } type chatSocketEvent struct { - Type string `json:"type"` - ClientID string `json:"clientId,omitempty"` - ConversationID string `json:"conversationId,omitempty"` - UserID string `json:"userId,omitempty"` - UserDisplayName string `json:"userDisplayName,omitempty"` - Typing bool `json:"typing,omitempty"` - Message *ChatMessage `json:"message,omitempty"` - Error string `json:"error,omitempty"` + Type string `json:"type"` + ClientID string `json:"clientId,omitempty"` + ConversationID string `json:"conversationId,omitempty"` + UserID string `json:"userId,omitempty"` + UserDisplayName string `json:"userDisplayName,omitempty"` + Typing bool `json:"typing,omitempty"` + Message *ChatMessage `json:"message,omitempty"` + Conversation *ChatConversation `json:"conversation,omitempty"` + Error string `json:"error,omitempty"` } type chatSocketClient struct { @@ -284,17 +285,7 @@ func (s *Server) publishChatMessage( ` SELECT cm.user_id::TEXT, cm.muted FROM conversation_members cm - JOIN conversations c ON c.id = cm.conversation_id WHERE cm.conversation_id = $1 - AND ( - c.type <> 'team' - OR EXISTS ( - SELECT 1 - FROM user_teams ut - WHERE ut.team_id = c.team_id - AND ut.user_id = cm.user_id - ) - ) `, message.ConversationID, ) @@ -325,7 +316,13 @@ func (s *Server) publishChatMessage( rows.Close() - title, conversationType, err := s.chatNotificationTitle(ctx, message) + // System-Nachrichten (Gruppen-Ereignisse, Selbstlöschen) erscheinen im + // Verlauf, lösen aber keine Benachrichtigung / Web-Push aus. + if message.Type == "system" { + return + } + + title, conversationType, conversationImage, err := s.chatNotificationTitle(ctx, message) if err != nil { return } @@ -340,12 +337,13 @@ func (s *Server) publishChatMessage( } data, err := json.Marshal(map[string]any{ - "conversationId": message.ConversationID, - "messageId": message.ID, - "senderId": message.Sender.ID, - "senderAvatar": message.Sender.Avatar, - "senderName": chatUserDisplayName(message.Sender), - "conversationType": conversationType, + "conversationId": message.ConversationID, + "messageId": message.ID, + "senderId": message.Sender.ID, + "senderAvatar": message.Sender.Avatar, + "senderName": chatUserDisplayName(message.Sender), + "conversationType": conversationType, + "conversationImage": conversationImage, }) if err != nil { return @@ -379,17 +377,7 @@ func (s *Server) chatConversationMemberIDs(ctx context.Context, conversationID s ` SELECT cm.user_id::TEXT FROM conversation_members cm - JOIN conversations c ON c.id = cm.conversation_id WHERE cm.conversation_id = $1 - AND ( - c.type <> 'team' - OR EXISTS ( - SELECT 1 - FROM user_teams ut - WHERE ut.team_id = c.team_id - AND ut.user_id = cm.user_id - ) - ) `, conversationID, ) @@ -426,6 +414,65 @@ func (s *Server) publishChatMessageUpdate(ctx context.Context, message ChatMessa } } +// publishConversationUpdate sendet jedem Mitglied die aktualisierte +// Konversation (Name/Bild/Selbstlöschen/Mitglieder), damit Seitenleiste und +// geöffneter Chat live aktualisiert werden. +func (s *Server) publishConversationUpdate(ctx context.Context, conversationID string) { + rows, err := s.db.Query( + ctx, + ` + SELECT cm.user_id::TEXT, u.show_last_seen + FROM conversation_members cm + JOIN users u ON u.id = cm.user_id + WHERE cm.conversation_id = $1 + `, + conversationID, + ) + if err != nil { + return + } + defer rows.Close() + + type member struct { + id string + showLastSeen bool + } + members := []member{} + for rows.Next() { + var m member + if err := rows.Scan(&m.id, &m.showLastSeen); err != nil { + return + } + members = append(members, m) + } + rows.Close() + + for _, m := range members { + conversation, err := s.getChatConversation(ctx, conversationID, m.id, m.showLastSeen) + if err != nil { + continue + } + chatSockets.publish(m.id, chatSocketEvent{ + Type: "conversation.updated", + Conversation: &conversation, + }) + } +} + +func (s *Server) publishChatMessagesReload(ctx context.Context, conversationID string) { + memberIDs, err := s.chatConversationMemberIDs(ctx, conversationID) + if err != nil { + return + } + + for _, memberID := range memberIDs { + chatSockets.publish(memberID, chatSocketEvent{ + Type: "messages.reload", + ConversationID: conversationID, + }) + } +} + func chatUserDisplayName(user ChatUser) string { if user.DisplayName != "" { return user.DisplayName @@ -439,31 +486,32 @@ func chatUserDisplayName(user ChatUser) string { func (s *Server) chatNotificationTitle( ctx context.Context, message ChatMessage, -) (string, string, error) { +) (string, string, string, error) { var conversationType string var conversationName string + var conversationImage string err := s.db.QueryRow( ctx, ` - SELECT type, name + SELECT type, name, channel_image FROM conversations WHERE id = $1 `, message.ConversationID, - ).Scan(&conversationType, &conversationName) + ).Scan(&conversationType, &conversationName, &conversationImage) if err != nil { - return "", "", err + return "", "", "", err } senderName := chatUserDisplayName(message.Sender) - if conversationType == "team" && conversationName != "" { - return senderName + " in " + conversationName, conversationType, nil + if conversationType == "group" && conversationName != "" { + return senderName + " in " + conversationName, conversationType, conversationImage, nil } if conversationType == "channel" && conversationName != "" { - return "Neue Meldung in " + conversationName, conversationType, nil + return "Neue Meldung in " + conversationName, conversationType, conversationImage, nil } - return "Neue Nachricht von " + senderName, conversationType, nil + return "Neue Nachricht von " + senderName, conversationType, conversationImage, nil } diff --git a/backend/cmd/vapid/main.go b/backend/cmd/vapid/main.go new file mode 100644 index 0000000..f2c56ea --- /dev/null +++ b/backend/cmd/vapid/main.go @@ -0,0 +1,96 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + "strings" + + webpush "github.com/SherClockHolmes/webpush-go" + "github.com/joho/godotenv" +) + +func main() { + writeEnv := flag.Bool("write-env", false, "VAPID-Schlüssel direkt in eine .env-Datei schreiben") + envPath := flag.String("env", ".env", "Pfad der zu aktualisierenden .env-Datei") + flag.Parse() + + _ = godotenv.Load() + + privateKey, publicKey, err := webpush.GenerateVAPIDKeys() + if err != nil { + log.Fatal(err) + } + + email := strings.TrimSpace(os.Getenv("ADMIN_EMAIL")) + if email == "" { + email = "admin@example.com" + } + + values := map[string]string{ + "VAPID_PUBLIC_KEY": publicKey, + "VAPID_PRIVATE_KEY": privateKey, + "VAPID_SUBSCRIBER": "mailto:" + email, + } + + if *writeEnv { + if err := writeVAPIDEnv(*envPath, values); err != nil { + log.Fatal(err) + } + fmt.Printf("VAPID-Konfiguration wurde in %s geschrieben.\n", *envPath) + return + } + + fmt.Printf("VAPID_PUBLIC_KEY=%s\n", values["VAPID_PUBLIC_KEY"]) + fmt.Printf("VAPID_PRIVATE_KEY=%s\n", values["VAPID_PRIVATE_KEY"]) + fmt.Printf("VAPID_SUBSCRIBER=%s\n", values["VAPID_SUBSCRIBER"]) +} + +func writeVAPIDEnv(path string, values map[string]string) error { + content, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return err + } + + lines := []string{} + if len(content) > 0 { + lines = strings.Split(strings.ReplaceAll(string(content), "\r\n", "\n"), "\n") + } + + for _, line := range lines { + key, value, found := strings.Cut(line, "=") + if !found { + continue + } + if (key == "VAPID_PUBLIC_KEY" || key == "VAPID_PRIVATE_KEY") && + strings.TrimSpace(value) != "" { + return fmt.Errorf("in %s sind bereits VAPID-Schlüssel vorhanden", path) + } + } + + updated := map[string]bool{} + for index, line := range lines { + key, _, found := strings.Cut(line, "=") + if !found { + continue + } + if value, ok := values[key]; ok { + lines[index] = key + "=" + value + updated[key] = true + } + } + + for _, key := range []string{ + "VAPID_PUBLIC_KEY", + "VAPID_PRIVATE_KEY", + "VAPID_SUBSCRIBER", + } { + if !updated[key] { + lines = append(lines, key+"="+values[key]) + } + } + + output := strings.TrimRight(strings.Join(lines, "\n"), "\n") + "\n" + return os.WriteFile(path, []byte(output), 0o600) +} diff --git a/backend/go.mod b/backend/go.mod index 355d6e1..66036ea 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -5,6 +5,7 @@ module main go 1.26.2 require ( + github.com/SherClockHolmes/webpush-go v1.4.0 github.com/coder/websocket v1.8.14 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/jackc/pgx/v5 v5.9.2 diff --git a/backend/go.sum b/backend/go.sum index 97d51c8..1d761a6 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,10 +1,14 @@ +github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s= +github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -22,12 +26,75 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/backend/main.go b/backend/main.go index 577602a..8c77a7c 100644 --- a/backend/main.go +++ b/backend/main.go @@ -38,6 +38,10 @@ func main() { server := NewServer(db, jwtSecret, frontendOrigin) server.startPresenceMonitor(ctx) + server.startDisappearingMessagesMonitor(ctx) + if !server.webPushConfigured() { + log.Println("Web Push ist nicht konfiguriert. VAPID_PUBLIC_KEY und VAPID_PRIVATE_KEY sowie eine Absenderadresse fehlen.") + } log.Printf("Backend läuft auf http://localhost:%s", port) diff --git a/backend/notification_preferences.go b/backend/notification_preferences.go index 890a428..20e5ceb 100644 --- a/backend/notification_preferences.go +++ b/backend/notification_preferences.go @@ -12,34 +12,48 @@ import ( ) type notificationPreferencesResponse struct { - InAppEnabled bool `json:"inAppEnabled"` - SoundEnabled bool `json:"soundEnabled"` - SoundName string `json:"soundName"` - EmailEnabled bool `json:"emailEnabled"` - OperationUpdates bool `json:"operationUpdates"` - OperationJournals bool `json:"operationJournals"` - DeviceUpdates bool `json:"deviceUpdates"` - DeviceJournals bool `json:"deviceJournals"` - SystemMessages bool `json:"systemMessages"` - ChatMessages bool `json:"chatMessages"` - ChannelMessages bool `json:"channelMessages"` - DesktopEnabled bool `json:"desktopEnabled"` + SoundEnabled bool `json:"soundEnabled"` + SoundName string `json:"soundName"` + EmailEnabled bool `json:"emailEnabled"` + DeviceUpdates bool `json:"deviceUpdates"` + DeviceJournals bool `json:"deviceJournals"` + ChatMessages bool `json:"chatMessages"` + ChannelMessages bool `json:"channelMessages"` + DesktopEnabled bool `json:"desktopEnabled"` + PushChatMessages bool `json:"pushChatMessages"` + PushChannelMessages bool `json:"pushChannelMessages"` } func defaultNotificationPreferences() notificationPreferencesResponse { return notificationPreferencesResponse{ - InAppEnabled: true, - SoundEnabled: false, - SoundName: "default", - EmailEnabled: false, - OperationUpdates: true, - OperationJournals: true, - DeviceUpdates: true, - DeviceJournals: true, - SystemMessages: true, - ChatMessages: true, - ChannelMessages: true, - DesktopEnabled: false, + SoundEnabled: false, + SoundName: "default", + EmailEnabled: false, + DeviceUpdates: true, + DeviceJournals: true, + ChatMessages: true, + ChannelMessages: true, + DesktopEnabled: false, + PushChatMessages: true, + PushChannelMessages: true, + } +} + +// shouldPushNotification entscheidet anhand der granularen Push-Einstellungen, +// ob für den jeweiligen Benachrichtigungstyp ein Web-Push gesendet werden soll. +// Es deckt aktuell die Nachrichtentypen ab, für die der Web-Push-Payload korrekt +// aufgebaut werden kann (Chat- und Channel-Nachrichten). +func shouldPushNotification( + preferences notificationPreferencesResponse, + notificationType string, +) bool { + switch notificationType { + case "channel.message": + return preferences.PushChannelMessages + case "chat.message": + return preferences.PushChatMessages + default: + return false } } @@ -100,49 +114,43 @@ func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *h ` INSERT INTO notification_preferences ( user_id, - in_app_enabled, sound_enabled, sound_name, email_enabled, - operation_updates, - operation_journals, device_updates, device_journals, - system_messages, chat_messages, channel_messages, - desktop_enabled + desktop_enabled, + push_chat_messages, + push_channel_messages ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ON CONFLICT (user_id) DO UPDATE SET - in_app_enabled = EXCLUDED.in_app_enabled, sound_enabled = EXCLUDED.sound_enabled, sound_name = EXCLUDED.sound_name, email_enabled = EXCLUDED.email_enabled, - operation_updates = EXCLUDED.operation_updates, - operation_journals = EXCLUDED.operation_journals, device_updates = EXCLUDED.device_updates, device_journals = EXCLUDED.device_journals, - system_messages = EXCLUDED.system_messages, chat_messages = EXCLUDED.chat_messages, channel_messages = EXCLUDED.channel_messages, desktop_enabled = EXCLUDED.desktop_enabled, + push_chat_messages = EXCLUDED.push_chat_messages, + push_channel_messages = EXCLUDED.push_channel_messages, updated_at = now() `, user.ID, - input.InAppEnabled, input.SoundEnabled, input.SoundName, input.EmailEnabled, - input.OperationUpdates, - input.OperationJournals, input.DeviceUpdates, input.DeviceJournals, - input.SystemMessages, input.ChatMessages, input.ChannelMessages, input.DesktopEnabled, + input.PushChatMessages, + input.PushChannelMessages, ) if err != nil { writeError(w, http.StatusInternalServerError, "Benachrichtigungseinstellungen konnten nicht gespeichert werden") @@ -167,36 +175,32 @@ func (s *Server) getNotificationPreferences(ctx context.Context, userID string) ctx, ` SELECT - in_app_enabled, sound_enabled, COALESCE(sound_name, 'default'), email_enabled, - operation_updates, - operation_journals, device_updates, device_journals, - system_messages, chat_messages, channel_messages, - desktop_enabled + desktop_enabled, + push_chat_messages, + push_channel_messages FROM notification_preferences WHERE user_id = $1 LIMIT 1 `, userID, ).Scan( - &settings.InAppEnabled, &settings.SoundEnabled, &settings.SoundName, &settings.EmailEnabled, - &settings.OperationUpdates, - &settings.OperationJournals, &settings.DeviceUpdates, &settings.DeviceJournals, - &settings.SystemMessages, &settings.ChatMessages, &settings.ChannelMessages, &settings.DesktopEnabled, + &settings.PushChatMessages, + &settings.PushChannelMessages, ) settings.SoundName = normalizeNotificationSoundName(settings.SoundName) diff --git a/backend/notifications.go b/backend/notifications.go index c680961..76c25f2 100644 --- a/backend/notifications.go +++ b/backend/notifications.go @@ -104,24 +104,14 @@ func shouldDeliverNotification( case strings.HasPrefix(notificationType, "chat.") || entityType == "chat": return preferences.ChatMessages - case !preferences.InAppEnabled: - return false - - case notificationType == "operation.journal": - return preferences.OperationJournals - - case strings.HasPrefix(notificationType, "operation.") || entityType == "operation": - return preferences.OperationUpdates - case notificationType == "device.journal": return preferences.DeviceJournals case strings.HasPrefix(notificationType, "device.") || entityType == "device": return preferences.DeviceUpdates - case strings.HasPrefix(notificationType, "system.") || entityType == "system": - return preferences.SystemMessages - + // Systemmeldungen werden immer ausgeliefert; Einsatz-Benachrichtigungen + // werden ausschließlich als stille Events gesendet (siehe createAndPublish). default: return true } @@ -157,7 +147,7 @@ func (s *Server) createAndPublishNotification( return nil } - inAppVisible := silent || (preferences.InAppEnabled && entityType != "chat") + inAppVisible := silent || entityType != "chat" var notification Notification var rawData []byte @@ -229,7 +219,7 @@ func (s *Server) createAndPublishNotification( if !notification.Silent { notification.SoundName = notificationSoundName(preferences) notification.Desktop = preferences.DesktopEnabled - notification.InApp = preferences.InAppEnabled + notification.InApp = true if notification.EntityType == "chat" { if notification.Type == "channel.message" { notification.InApp = preferences.ChannelMessages @@ -240,6 +230,11 @@ func (s *Server) createAndPublishNotification( } notificationsBroker.publish(notification) + if !notification.Silent && + preferences.DesktopEnabled && + shouldPushNotification(preferences, notification.Type) { + s.publishWebPushAsync(notification) + } return nil } @@ -478,6 +473,8 @@ func (s *Server) createOperationNotification( return err } + // Einsatz-Benachrichtigungen werden ausschließlich als stille Events + // gesendet (kein sichtbarer Toast / keine Notification-Center-Einträge). if err := s.createAndPublishNotification( ctx, recipientID, @@ -487,7 +484,7 @@ func (s *Server) createOperationNotification( "operation", operation.ID, dataJSON, - false, + true, ); err != nil { return err } diff --git a/backend/push_notifications.go b/backend/push_notifications.go new file mode 100644 index 0000000..01cfb9d --- /dev/null +++ b/backend/push_notifications.go @@ -0,0 +1,508 @@ +package main + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "hash/fnv" + "html" + "io" + "log" + "net/http" + "net/url" + "strings" + "time" + + webpush "github.com/SherClockHolmes/webpush-go" +) + +const ( + maxPushSubscriptionBodyBytes = 16 * 1024 + maxPushIconImageBytes = 3 * 1024 * 1024 +) + +type pushSubscriptionRequest struct { + Endpoint string `json:"endpoint"` + APIBaseURL string `json:"apiBaseUrl"` + Keys struct { + P256dh string `json:"p256dh"` + Auth string `json:"auth"` + } `json:"keys"` +} + +type storedPushSubscription struct { + Endpoint string + P256dh string + Auth string + APIBaseURL string +} + +type webPushPayload struct { + Title string `json:"title"` + Body string `json:"body"` + Icon string `json:"icon,omitempty"` + Badge string `json:"badge,omitempty"` + Tag string `json:"tag,omitempty"` + URL string `json:"url"` +} + +func (s *Server) webPushConfigured() bool { + return s.vapidPublicKey != "" && + s.vapidPrivateKey != "" && + s.vapidSubscriber != "" +} + +func (s *Server) handleGetPushConfig(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "enabled": s.webPushConfigured(), + "publicKey": s.vapidPublicKey, + }) +} + +func (s *Server) handleSavePushSubscription(w http.ResponseWriter, r *http.Request) { + if !s.webPushConfigured() { + writeError(w, http.StatusServiceUnavailable, "Web Push ist auf dem Server noch nicht konfiguriert") + return + } + + user, userOK := userFromContext(r.Context()) + sessionID, sessionOK := s.currentSessionIDFromRequest(r) + if !userOK || !sessionOK { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + r.Body = http.MaxBytesReader(w, r.Body, maxPushSubscriptionBodyBytes) + var input pushSubscriptionRequest + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Ungültiges Push-Abonnement") + return + } + + input.Endpoint = strings.TrimSpace(input.Endpoint) + input.Keys.P256dh = strings.TrimSpace(input.Keys.P256dh) + input.Keys.Auth = strings.TrimSpace(input.Keys.Auth) + apiBaseURL, err := normalizePushAPIBaseURL(input.APIBaseURL) + if err != nil || + input.Endpoint == "" || + input.Keys.P256dh == "" || + input.Keys.Auth == "" { + writeError(w, http.StatusBadRequest, "Ungültiges Push-Abonnement") + return + } + + endpointURL, err := url.Parse(input.Endpoint) + if err != nil || + endpointURL.Host == "" || + (endpointURL.Scheme != "http" && endpointURL.Scheme != "https") { + writeError(w, http.StatusBadRequest, "Ungültiger Push-Endpunkt") + return + } + + _, err = s.db.Exec( + r.Context(), + ` + INSERT INTO push_subscriptions ( + endpoint, + user_id, + session_id, + p256dh, + auth, + api_base_url, + user_agent, + expires_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, now() + interval '24 hours') + ON CONFLICT (endpoint) + DO UPDATE SET + user_id = EXCLUDED.user_id, + session_id = EXCLUDED.session_id, + p256dh = EXCLUDED.p256dh, + auth = EXCLUDED.auth, + api_base_url = EXCLUDED.api_base_url, + user_agent = EXCLUDED.user_agent, + expires_at = EXCLUDED.expires_at, + updated_at = now() + `, + input.Endpoint, + user.ID, + sessionID, + input.Keys.P256dh, + input.Keys.Auth, + apiBaseURL, + strings.TrimSpace(r.UserAgent()), + ) + if err != nil { + writeError(w, http.StatusInternalServerError, "Push-Abonnement konnte nicht gespeichert werden") + return + } + + writeJSON(w, http.StatusOK, map[string]bool{"subscribed": true}) +} + +func (s *Server) handleDeletePushSubscription(w http.ResponseWriter, r *http.Request) { + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + r.Body = http.MaxBytesReader(w, r.Body, maxPushSubscriptionBodyBytes) + var input struct { + Endpoint string `json:"endpoint"` + } + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Ungültiges Push-Abonnement") + return + } + + _, err := s.db.Exec( + r.Context(), + `DELETE FROM push_subscriptions WHERE endpoint = $1 AND user_id = $2`, + strings.TrimSpace(input.Endpoint), + user.ID, + ) + if err != nil { + writeError(w, http.StatusInternalServerError, "Push-Abonnement konnte nicht entfernt werden") + return + } + + writeJSON(w, http.StatusOK, map[string]bool{"subscribed": false}) +} + +func normalizePushAPIBaseURL(value string) (string, error) { + value = strings.TrimRight(strings.TrimSpace(value), "/") + if value == "" || len(value) > 500 { + return "", errors.New("ungültige API-URL") + } + + parsed, err := url.Parse(value) + if err != nil || + (parsed.Scheme != "http" && parsed.Scheme != "https") || + parsed.Host == "" { + return "", errors.New("ungültige API-URL") + } + + return value, nil +} + +func (s *Server) publishWebPushAsync(notification Notification) { + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := s.publishWebPush(ctx, notification); err != nil { + log.Printf("Web Push für User %s fehlgeschlagen: %v", notification.UserID, err) + } + }() +} + +func (s *Server) publishWebPush(ctx context.Context, notification Notification) error { + rows, err := s.db.Query( + ctx, + ` + SELECT + ps.endpoint, + ps.p256dh, + ps.auth, + ps.api_base_url + FROM push_subscriptions ps + JOIN user_sessions us ON us.id = ps.session_id + WHERE ps.user_id = $1 + AND us.revoked_at IS NULL + AND ps.expires_at > now() + `, + notification.UserID, + ) + if err != nil { + return err + } + defer rows.Close() + + subscriptions := []storedPushSubscription{} + for rows.Next() { + var subscription storedPushSubscription + if err := rows.Scan( + &subscription.Endpoint, + &subscription.P256dh, + &subscription.Auth, + &subscription.APIBaseURL, + ); err != nil { + return err + } + subscriptions = append(subscriptions, subscription) + } + if err := rows.Err(); err != nil { + return err + } + + for _, subscription := range subscriptions { + payload, err := s.buildWebPushPayload(notification, subscription.APIBaseURL) + if err != nil { + continue + } + + payloadJSON, err := json.Marshal(payload) + if err != nil { + continue + } + + response, err := webpush.SendNotificationWithContext( + ctx, + payloadJSON, + &webpush.Subscription{ + Endpoint: subscription.Endpoint, + Keys: webpush.Keys{ + P256dh: subscription.P256dh, + Auth: subscription.Auth, + }, + }, + &webpush.Options{ + Subscriber: s.vapidSubscriber, + VAPIDPublicKey: s.vapidPublicKey, + VAPIDPrivateKey: s.vapidPrivateKey, + TTL: 60 * 60, + HTTPClient: &http.Client{Timeout: 12 * time.Second}, + }, + ) + if err != nil { + continue + } + + _, _ = io.Copy(io.Discard, response.Body) + _ = response.Body.Close() + + if response.StatusCode == http.StatusGone || + response.StatusCode == http.StatusNotFound { + cleanupCtx, cleanupCancel := context.WithTimeout( + context.Background(), + 5*time.Second, + ) + _, _ = s.db.Exec( + cleanupCtx, + `DELETE FROM push_subscriptions WHERE endpoint = $1`, + subscription.Endpoint, + ) + cleanupCancel() + } + } + + return nil +} + +func (s *Server) buildWebPushPayload( + notification Notification, + apiBaseURL string, +) (webPushPayload, error) { + var data struct { + SenderID string `json:"senderId"` + SenderAvatar string `json:"senderAvatar"` + ConversationType string `json:"conversationType"` + ConversationImage string `json:"conversationImage"` + } + if err := json.Unmarshal(notification.Data, &data); err != nil { + return webPushPayload{}, err + } + + path := "/chat/" + notification.EntityID + if data.ConversationType == "channel" { + path = "/chat/channel/" + notification.EntityID + } + + iconKind := "user" + iconID := data.SenderID + iconSource := strings.TrimSpace(data.SenderAvatar) + if data.ConversationType == "group" { + iconKind = "group" + iconID = notification.EntityID + iconSource = strings.TrimSpace(data.ConversationImage) + } else if data.ConversationType == "channel" || iconID == "" { + iconKind = "channel" + iconID = notification.EntityID + iconSource = strings.TrimSpace(data.ConversationImage) + } + + // Externe Bild-URLs direkt verwenden. Für lokale Avatare (data:-URLs) oder + // fehlende Avatare auf den signierten Icon-Endpunkt verweisen, der entweder + // das hinterlegte Bild ausliefert oder ein Initialen-Icon erzeugt. + iconURL := iconSource + isRemote := strings.HasPrefix(iconURL, "https://") || + strings.HasPrefix(iconURL, "http://") + if !isRemote { + iconURL = "" + if apiBaseURL != "" && iconID != "" { + signature := s.signPushIcon(iconKind, iconID) + iconURL = apiBaseURL + "/push/icons/" + iconKind + "/" + iconID + "/" + signature + iconURL += "?v=" + pushIconVersion(iconSource) + } + } + + return webPushPayload{ + Title: notification.Title, + Body: notification.Message, + Icon: iconURL, + Tag: "chat-" + notification.EntityID, + URL: path, + }, nil +} + +func pushIconVersion(image string) string { + sum := sha256.Sum256([]byte(image)) + return hex.EncodeToString(sum[:8]) +} + +func (s *Server) signPushIcon(kind string, id string) string { + mac := hmac.New(sha256.New, s.jwtSecret) + _, _ = mac.Write([]byte(kind + ":" + id)) + return hex.EncodeToString(mac.Sum(nil)) +} + +func (s *Server) handlePushIcon(w http.ResponseWriter, r *http.Request) { + kind := strings.TrimSpace(r.PathValue("kind")) + id := strings.TrimSpace(r.PathValue("id")) + signature := strings.TrimSpace(r.PathValue("signature")) + expectedSignature := s.signPushIcon(kind, id) + + if subtle.ConstantTimeCompare( + []byte(signature), + []byte(expectedSignature), + ) != 1 { + http.NotFound(w, r) + return + } + + var avatar string + var label string + var err error + switch kind { + case "user": + err = s.db.QueryRow( + r.Context(), + `SELECT COALESCE(avatar, ''), COALESCE(NULLIF(display_name, ''), username, '') FROM users WHERE id = $1`, + id, + ).Scan(&avatar, &label) + case "channel": + err = s.db.QueryRow( + r.Context(), + ` + SELECT COALESCE(channel_image, ''), COALESCE(name, '') + FROM conversations + WHERE id = $1 AND type = 'channel' + `, + id, + ).Scan(&avatar, &label) + case "group": + err = s.db.QueryRow( + r.Context(), + ` + SELECT COALESCE(channel_image, ''), COALESCE(name, '') + FROM conversations + WHERE id = $1 AND type = 'group' + `, + id, + ).Scan(&avatar, &label) + default: + http.NotFound(w, r) + return + } + + if err != nil { + http.NotFound(w, r) + return + } + + if avatar != "" { + if contentType, image, decodeErr := decodeAvatarDataURL(avatar); decodeErr == nil { + w.Header().Set("Content-Type", contentType) + w.Header().Set("Cache-Control", "public, max-age=3600") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(image) + return + } + } + + // Kein (dekodierbares) Bild hinterlegt: deterministisches Initialen-Icon + // erzeugen, damit die Benachrichtigung trotzdem den Absender zeigt. + w.Header().Set("Content-Type", "image/svg+xml") + w.Header().Set("Cache-Control", "public, max-age=3600") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(initialsIconSVG(label, kind+":"+id)) +} + +func initialsIconSVG(label string, seed string) []byte { + initials := deriveInitials(label) + hue := hueFromSeed(seed) + background := fmt.Sprintf("hsl(%d, 60%%, 45%%)", hue) + + svg := fmt.Sprintf( + ``, + background, + html.EscapeString(initials), + ) + + return []byte(svg) +} + +func deriveInitials(label string) string { + fields := strings.Fields(label) + if len(fields) == 0 { + return "?" + } + + firstRune := func(value string) string { + for _, r := range value { + return strings.ToUpper(string(r)) + } + return "" + } + + if len(fields) == 1 { + runes := []rune(fields[0]) + if len(runes) >= 2 { + return strings.ToUpper(string(runes[:2])) + } + return strings.ToUpper(string(runes)) + } + + return firstRune(fields[0]) + firstRune(fields[len(fields)-1]) +} + +func hueFromSeed(seed string) int { + hash := fnv.New32a() + _, _ = hash.Write([]byte(seed)) + return int(hash.Sum32() % 360) +} + +func decodeAvatarDataURL(value string) (string, []byte, error) { + metadata, encoded, found := strings.Cut(value, ",") + if !found || !strings.HasSuffix(metadata, ";base64") { + return "", nil, errors.New("ungültiges Avatarformat") + } + + contentType := strings.TrimSuffix(strings.TrimPrefix(metadata, "data:"), ";base64") + switch contentType { + case "image/png", "image/jpeg", "image/webp", "image/gif": + default: + return "", nil, errors.New("nicht unterstütztes Avatarformat") + } + + image, err := base64.StdEncoding.DecodeString(encoded) + if err != nil || len(image) == 0 || len(image) > maxPushIconImageBytes { + return "", nil, errors.New("ungültige Avatardaten") + } + + return contentType, image, nil +} diff --git a/backend/routes.go b/backend/routes.go index 2c39dd4..a28311b 100644 --- a/backend/routes.go +++ b/backend/routes.go @@ -11,6 +11,7 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("GET /health", s.handleHealth) mux.HandleFunc("POST /webhooks/channels/{slug}", s.handleChannelWebhook) + mux.HandleFunc("GET /push/icons/{kind}/{id}/{signature}", s.handlePushIcon) mux.HandleFunc("POST /auth/register", s.handleRegister) mux.HandleFunc("POST /auth/login", s.handleLogin) @@ -26,6 +27,9 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("GET /settings/notifications/preferences", s.requireAuth(s.handleGetNotificationPreferences)) mux.HandleFunc("PUT /settings/notifications/preferences", s.requireAuth(s.handleUpdateNotificationPreferences)) + mux.HandleFunc("GET /push/config", s.requireAuth(s.handleGetPushConfig)) + mux.HandleFunc("POST /push/subscriptions", s.requireAuth(s.handleSavePushSubscription)) + mux.HandleFunc("DELETE /push/subscriptions", s.requireAuth(s.handleDeletePushSubscription)) mux.HandleFunc("POST /feedback", s.requireAuth(s.handleCreateFeedback)) @@ -86,6 +90,7 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("GET /settings/presence", s.requireAuth(s.handleGetPresenceSettings)) mux.HandleFunc("PUT /settings/presence", s.requireAuth(s.handleUpdatePresenceSettings)) mux.HandleFunc("PUT /settings/presence/status", s.requireAuth(s.handleUpdatePresenceStatus)) + mux.HandleFunc("GET /calendar/settings", s.requireAuth(s.requireRight("calendar:read", s.handleGetCalendarSettings))) mux.HandleFunc("GET /chat/conversations", s.requireAuth(s.handleListChatConversations)) mux.HandleFunc("GET /chat/search", s.requireAuth(s.handleSearchChatMessages)) @@ -100,6 +105,12 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("POST /chat/conversations/{id}/messages", s.requireAuth(s.handleCreateChatMessage)) mux.HandleFunc("POST /chat/conversations/{id}/read", s.requireAuth(s.handleMarkChatConversationRead)) mux.HandleFunc("PUT /chat/conversations/{id}/mute", s.requireAuth(s.handleUpdateConversationMute)) + mux.HandleFunc("PUT /chat/conversations/{id}/disappearing", s.requireAuth(s.handleUpdateDisappearing)) + mux.HandleFunc("POST /chat/groups", s.requireAuth(s.handleCreateGroup)) + mux.HandleFunc("PUT /chat/groups/{id}", s.requireAuth(s.handleUpdateGroup)) + mux.HandleFunc("DELETE /chat/groups/{id}", s.requireAuth(s.handleDeleteGroup)) + mux.HandleFunc("POST /chat/groups/{id}/members", s.requireAuth(s.handleAddGroupMembers)) + mux.HandleFunc("DELETE /chat/groups/{id}/members/{userId}", s.requireAuth(s.handleRemoveGroupMember)) mux.HandleFunc("GET /dashboard/operation-changes", s.requireAuth(s.requireRight("operations:read", s.handleDashboardOperationChanges))) @@ -130,6 +141,11 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("POST /admin/channels", s.requireAuth(s.requireRight("administration:write", s.handleAdminCreateChannel))) mux.HandleFunc("PUT /admin/channels/{id}", s.requireAuth(s.requireRight("administration:write", s.handleAdminUpdateChannel))) mux.HandleFunc("POST /admin/channels/{id}/token", s.requireAuth(s.requireRight("administration:write", s.handleAdminRotateChannelToken))) + mux.HandleFunc("GET /admin/group-chats", s.requireAuth(s.requireRight("administration:read", s.handleAdminListGroupChats))) + mux.HandleFunc("PUT /admin/group-chats/{id}", s.requireAuth(s.requireRight("administration:write", s.handleAdminUpdateGroupChat))) + mux.HandleFunc("DELETE /admin/group-chats/{id}", s.requireAuth(s.requireRight("administration:write", s.handleAdminDeleteGroupChat))) + mux.HandleFunc("GET /admin/calendar/settings", s.requireAuth(s.requireRight("administration:read", s.handleGetCalendarSettings))) + mux.HandleFunc("PUT /admin/calendar/settings", s.requireAuth(s.requireRight("administration:write", s.handleUpdateCalendarSettings))) /* milestone settings */ diff --git a/backend/server.go b/backend/server.go index dca70f8..9fbf3be 100644 --- a/backend/server.go +++ b/backend/server.go @@ -7,6 +7,7 @@ import ( "crypto/subtle" "errors" "net/http" + "os" "strings" "sync" "time" @@ -25,11 +26,14 @@ const ( ) type Server struct { - db *pgxpool.Pool - jwtSecret []byte - frontendOrigin string - presenceMu sync.Mutex - presenceStatus map[string]string + db *pgxpool.Pool + jwtSecret []byte + frontendOrigin string + vapidPublicKey string + vapidPrivateKey string + vapidSubscriber string + presenceMu sync.Mutex + presenceStatus map[string]string } type User struct { @@ -92,11 +96,21 @@ type Claims struct { } func NewServer(db *pgxpool.Pool, jwtSecret string, frontendOrigin string) *Server { + vapidSubscriber := strings.TrimSpace(os.Getenv("VAPID_SUBSCRIBER")) + if vapidSubscriber == "" { + if adminEmail := strings.TrimSpace(os.Getenv("ADMIN_EMAIL")); adminEmail != "" { + vapidSubscriber = "mailto:" + adminEmail + } + } + return &Server{ - db: db, - jwtSecret: []byte(jwtSecret), - frontendOrigin: frontendOrigin, - presenceStatus: map[string]string{}, + db: db, + jwtSecret: []byte(jwtSecret), + frontendOrigin: frontendOrigin, + vapidPublicKey: strings.TrimSpace(os.Getenv("VAPID_PUBLIC_KEY")), + vapidPrivateKey: strings.TrimSpace(os.Getenv("VAPID_PRIVATE_KEY")), + vapidSubscriber: vapidSubscriber, + presenceStatus: map[string]string{}, } } @@ -252,6 +266,17 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { } if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(input.Password)); err != nil { + // Fehlgeschlagene Logins werden protokolliert. Diese Einträge bleiben + // erhalten, da das Protokoll nur bei erfolgreichem Login geleert wird. + s.logUserEvent(r.Context(), UserLogEntry{ + User: &user, + Request: r, + Level: UserLogLevelWarning, + Category: "auth", + Action: "login", + Message: "Login fehlgeschlagen: falsches Passwort", + }) + writeError(w, http.StatusUnauthorized, "Invalid username/email or password") return } @@ -273,10 +298,23 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { user.LastSeenAt = &now if err := s.setSessionCookie(w, user, sessionID); err != nil { + s.logUserError( + r, + user, + "auth", + "login", + "Sitzungs-Cookie konnte nicht erstellt werden", + err, + nil, + ) writeError(w, http.StatusInternalServerError, "Could not create session") return } + // Erfolgreicher Login: Protokoll des Users vollständig leeren (inklusive des + // soeben erzeugten Sitzungs-Eintrags), damit es danach komplett leer ist. + s.deleteUserLogs(r.Context(), user.ID) + writeJSON(w, http.StatusOK, map[string]any{ "user": user, }) @@ -284,6 +322,12 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { if sessionID, ok := s.currentSessionIDFromCookie(r); ok { + _, _ = s.db.Exec( + r.Context(), + `DELETE FROM push_subscriptions WHERE session_id = $1`, + sessionID, + ) + _, _ = s.db.Exec( r.Context(), ` diff --git a/backend/setup/main.go b/backend/setup/main.go index 5305ee2..0eefd6e 100644 --- a/backend/setup/main.go +++ b/backend/setup/main.go @@ -385,6 +385,28 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { ) `, + ` + CREATE TABLE IF NOT EXISTS calendar_settings ( + id SMALLINT PRIMARY KEY CHECK (id = 1), + core_working_hours JSONB NOT NULL DEFAULT '{ + "monday": {"enabled": true, "start": "08:00", "end": "17:00"}, + "tuesday": {"enabled": true, "start": "08:00", "end": "17:00"}, + "wednesday": {"enabled": true, "start": "08:00", "end": "17:00"}, + "thursday": {"enabled": true, "start": "08:00", "end": "17:00"}, + "friday": {"enabled": true, "start": "08:00", "end": "17:00"}, + "saturday": {"enabled": false, "start": "08:00", "end": "17:00"}, + "sunday": {"enabled": false, "start": "08:00", "end": "17:00"} + }'::JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `, + ` + INSERT INTO calendar_settings (id) + VALUES (1) + ON CONFLICT (id) DO NOTHING + `, + ` CREATE TABLE IF NOT EXISTS devices ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -625,6 +647,8 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { chat_messages BOOLEAN NOT NULL DEFAULT true, channel_messages BOOLEAN NOT NULL DEFAULT true, desktop_enabled BOOLEAN NOT NULL DEFAULT false, + push_chat_messages BOOLEAN NOT NULL DEFAULT true, + push_channel_messages BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() @@ -634,6 +658,24 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { `ALTER TABLE IF EXISTS notification_preferences ADD COLUMN IF NOT EXISTS chat_messages BOOLEAN NOT NULL DEFAULT true`, `ALTER TABLE IF EXISTS notification_preferences ADD COLUMN IF NOT EXISTS channel_messages BOOLEAN NOT NULL DEFAULT true`, `ALTER TABLE IF EXISTS notification_preferences ADD COLUMN IF NOT EXISTS desktop_enabled BOOLEAN NOT NULL DEFAULT false`, + `ALTER TABLE IF EXISTS notification_preferences ADD COLUMN IF NOT EXISTS push_chat_messages BOOLEAN NOT NULL DEFAULT true`, + `ALTER TABLE IF EXISTS notification_preferences ADD COLUMN IF NOT EXISTS push_channel_messages BOOLEAN NOT NULL DEFAULT true`, + + ` + CREATE TABLE IF NOT EXISTS push_subscriptions ( + endpoint TEXT PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + session_id UUID NOT NULL REFERENCES user_sessions(id) ON DELETE CASCADE, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + api_base_url TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + expires_at TIMESTAMPTZ NOT NULL DEFAULT now() + interval '24 hours', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `, + `ALTER TABLE IF EXISTS push_subscriptions ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ NOT NULL DEFAULT now() + interval '24 hours'`, ` CREATE TABLE IF NOT EXISTS feedback ( @@ -707,7 +749,7 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { id UUID PRIMARY KEY DEFAULT gen_random_uuid(), type TEXT NOT NULL DEFAULT 'direct' - CHECK (type IN ('team', 'direct', 'group', 'channel')), + CHECK (type IN ('direct', 'group', 'channel')), name TEXT NOT NULL DEFAULT '', team_id UUID REFERENCES teams(id) ON DELETE CASCADE, @@ -727,12 +769,16 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { last_message_at TIMESTAMPTZ, + disappearing_seconds INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) `, `ALTER TABLE IF EXISTS conversations DROP CONSTRAINT IF EXISTS conversations_type_check`, - `ALTER TABLE IF EXISTS conversations ADD CONSTRAINT conversations_type_check CHECK (type IN ('team', 'direct', 'group', 'channel'))`, + `ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS disappearing_seconds INTEGER NOT NULL DEFAULT 0`, + `UPDATE conversations SET type = 'group', created_by = NULL WHERE type = 'team'`, + `ALTER TABLE IF EXISTS conversations ADD CONSTRAINT conversations_type_check CHECK (type IN ('direct', 'group', 'channel'))`, `ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS slug TEXT NOT NULL DEFAULT ''`, `ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS channel_source_name TEXT NOT NULL DEFAULT ''`, `ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS channel_image TEXT NOT NULL DEFAULT ''`, @@ -768,6 +814,10 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { reply_to_message_id UUID REFERENCES messages(id) ON DELETE SET NULL, forwarded_from_message_id UUID REFERENCES messages(id) ON DELETE SET NULL, + message_type TEXT NOT NULL DEFAULT 'user', + system_event JSONB, + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), edited_at TIMESTAMPTZ, deleted_at TIMESTAMPTZ @@ -776,6 +826,9 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { `ALTER TABLE IF EXISTS messages ADD COLUMN IF NOT EXISTS reply_to_message_id UUID REFERENCES messages(id) ON DELETE SET NULL`, `ALTER TABLE IF EXISTS messages ADD COLUMN IF NOT EXISTS forwarded_from_message_id UUID REFERENCES messages(id) ON DELETE SET NULL`, + `ALTER TABLE IF EXISTS messages ADD COLUMN IF NOT EXISTS message_type TEXT NOT NULL DEFAULT 'user'`, + `ALTER TABLE IF EXISTS messages ADD COLUMN IF NOT EXISTS system_event JSONB`, + `ALTER TABLE IF EXISTS messages ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ`, // Standort-Nachrichten: lat/lng sind NULL, wenn die Nachricht keinen Standort enthält. `ALTER TABLE IF EXISTS messages ADD COLUMN IF NOT EXISTS location_lat DOUBLE PRECISION`, @@ -877,6 +930,8 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { `CREATE INDEX IF NOT EXISTS idx_notifications_entity ON notifications(entity_type, entity_id)`, `CREATE INDEX IF NOT EXISTS idx_notification_preferences_user_id ON notification_preferences(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id ON push_subscriptions(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_push_subscriptions_session_id ON push_subscriptions(session_id)`, `CREATE INDEX IF NOT EXISTS idx_feedback_user_id ON feedback(user_id)`, `CREATE INDEX IF NOT EXISTS idx_feedback_created_at ON feedback(created_at)`, @@ -896,6 +951,38 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { `CREATE INDEX IF NOT EXISTS idx_conversations_type ON conversations(type)`, `CREATE INDEX IF NOT EXISTS idx_conversations_last_message_at ON conversations(last_message_at DESC)`, ` + INSERT INTO conversations (type, name, team_id) + SELECT 'group', name, id + FROM teams + ON CONFLICT (team_id) WHERE team_id IS NOT NULL + DO UPDATE SET + type = 'group', + name = EXCLUDED.name, + created_by = NULL, + updated_at = now() + `, + ` + DELETE FROM conversation_members cm + USING conversations c + WHERE cm.conversation_id = c.id + AND c.team_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM user_teams ut + WHERE ut.team_id = c.team_id + AND ut.user_id = cm.user_id + ) + `, + ` + INSERT INTO conversation_members (conversation_id, user_id) + SELECT c.id, ut.user_id + FROM conversations c + JOIN user_teams ut ON ut.team_id = c.team_id + WHERE c.type = 'group' + AND c.team_id IS NOT NULL + ON CONFLICT (conversation_id, user_id) DO NOTHING + `, + ` INSERT INTO conversations ( type, name, @@ -925,6 +1012,7 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { `CREATE INDEX IF NOT EXISTS idx_messages_sender_id ON messages(sender_id)`, `CREATE INDEX IF NOT EXISTS idx_messages_reply_to_message_id ON messages(reply_to_message_id)`, `CREATE INDEX IF NOT EXISTS idx_messages_forwarded_from_message_id ON messages(forwarded_from_message_id)`, + `CREATE INDEX IF NOT EXISTS idx_messages_expires_at ON messages(expires_at) WHERE expires_at IS NOT NULL`, `CREATE INDEX IF NOT EXISTS idx_message_attachments_message_id ON message_attachments(message_id)`, `CREATE INDEX IF NOT EXISTS idx_message_attachments_uploader_id ON message_attachments(uploader_id)`, } diff --git a/backend/user_log.go b/backend/user_log.go index 532b913..a8ae9e0 100644 --- a/backend/user_log.go +++ b/backend/user_log.go @@ -128,6 +128,27 @@ func (s *Server) logUserEvent(ctx context.Context, entry UserLogEntry) { s.pruneAnonymousUserLogs(ctx) } +// deleteUserLogs entfernt sämtliche Protokolleinträge eines Users. Wird nach +// einem erfolgreichen Login aufgerufen, damit das Protokoll danach leer ist. +func (s *Server) deleteUserLogs(ctx context.Context, userID string) { + userID = strings.TrimSpace(userID) + + if userID == "" { + return + } + + _, err := s.db.Exec( + ctx, + `DELETE FROM user_logs WHERE user_id = $1`, + userID, + ) + if err != nil { + logError("User-Logs konnten beim Login nicht gelöscht werden", err, LogFields{ + "userId": userID, + }) + } +} + func (s *Server) pruneUserLogs(ctx context.Context, user *User) { if user == nil || user.ID == "" { return diff --git a/backend/user_sessions.go b/backend/user_sessions.go index da6771c..74167c9 100644 --- a/backend/user_sessions.go +++ b/backend/user_sessions.go @@ -397,6 +397,12 @@ func (s *Server) handleRevokeUserSession(w http.ResponseWriter, r *http.Request) return } + _, _ = s.db.Exec( + r.Context(), + `DELETE FROM push_subscriptions WHERE session_id = $1`, + sessionID, + ) + s.logRequestInfo( r, "session", diff --git a/backend/user_settings.go b/backend/user_settings.go index 84134f1..92c7e51 100644 --- a/backend/user_settings.go +++ b/backend/user_settings.go @@ -245,6 +245,17 @@ func (s *Server) handleLogoutOtherSessions(w http.ResponseWriter, r *http.Reques return } + _, _ = s.db.Exec( + r.Context(), + ` + DELETE FROM push_subscriptions + WHERE user_id = $1 + AND session_id <> $2 + `, + userID, + currentSessionID, + ) + writeSettingsJSON(w, http.StatusOK, map[string]any{ "message": "Andere Sitzungen wurden abgemeldet", }) diff --git a/frontend/.env.development b/frontend/.env.development new file mode 100644 index 0000000..19c85cd --- /dev/null +++ b/frontend/.env.development @@ -0,0 +1 @@ +VITE_API_URL="/api" diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf3..e99b50c 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -12,6 +12,9 @@ dist dist-ssr *.local +# Local dev TLS certificates (mkcert) +certs + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/frontend/index.html b/frontend/index.html index 4d75eed..8a8d263 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,8 +3,11 @@
+ -+ Keine Termine an diesem Tag. +
+ ) : ( ++ Kalender +
+- Benutzer verwalten, Teams zuweisen und Integrationen konfigurieren. + Benutzer, Kommunikation, Kalender und Integrationen verwalten.
{section === 'benutzer' &&+ Lege für jeden Wochentag den hervorgehobenen Arbeitszeitraum fest. + Deaktivierte Tage erhalten im Kalender keine Markierung. +
++ Neue Gruppenchats werden im Chat erstellt und können anschließend hier + administriert werden. +
+