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( + ``+ + ``+ + `%s`+ + ``, + 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 @@ + - teg + + + TEG
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3d53f0b..d4b97b8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -27,6 +27,7 @@ "@types/node": "^24.12.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@vitejs/plugin-basic-ssl": "^2.3.0", "@vitejs/plugin-react": "^6.0.1", "eslint": "^10.3.0", "eslint-plugin-react-hooks": "^7.1.1", @@ -2250,6 +2251,19 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/@vitejs/plugin-basic-ssl": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.3.0.tgz", + "integrity": "sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "peerDependencies": { + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7aae7d1..8b8d4d2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,6 +29,7 @@ "@types/node": "^24.12.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@vitejs/plugin-basic-ssl": "^2.3.0", "@vitejs/plugin-react": "^6.0.1", "eslint": "^10.3.0", "eslint-plugin-react-hooks": "^7.1.1", diff --git a/frontend/public/manifest.webmanifest b/frontend/public/manifest.webmanifest new file mode 100644 index 0000000..a87556a --- /dev/null +++ b/frontend/public/manifest.webmanifest @@ -0,0 +1,18 @@ +{ + "name": "TEG", + "short_name": "TEG", + "description": "TEG Einsatz-, Geräte- und Chatverwaltung", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#030712", + "theme_color": "#4f46e5", + "icons": [ + { + "src": "/favicon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + } + ] +} diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 0000000..024a608 --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,64 @@ +const DEFAULT_ICON = '/favicon.svg' + +self.addEventListener('install', () => { + self.skipWaiting() +}) + +self.addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()) +}) + +self.addEventListener('push', (event) => { + let payload = {} + + try { + payload = event.data ? event.data.json() : {} + } catch { + payload = { + title: 'Neue TEG-Nachricht', + body: event.data ? event.data.text() : '', + url: '/chat', + } + } + + const title = payload.title || 'Neue TEG-Nachricht' + const icon = payload.icon || DEFAULT_ICON + + event.waitUntil( + self.registration.showNotification(title, { + body: payload.body || '', + icon, + badge: payload.badge || DEFAULT_ICON, + tag: payload.tag || 'teg-message', + renotify: true, + requireInteraction: true, + data: { + url: payload.url || '/chat', + }, + }), + ) +}) + +self.addEventListener('notificationclick', (event) => { + event.notification.close() + + const targetURL = new URL( + event.notification.data?.url || '/chat', + self.location.origin, + ).href + + event.waitUntil( + self.clients + .matchAll({ type: 'window', includeUncontrolled: true }) + .then(async (windowClients) => { + for (const client of windowClients) { + if ('navigate' in client) { + await client.navigate(targetURL) + } + return client.focus() + } + + return self.clients.openWindow(targetURL) + }), + ) +}) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ed013ca..e3cb98f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,7 +8,7 @@ import AppLayout from './components/AppLayout' import DashboardPage from './pages/DashboardPage' import DevicesPage from './pages/devices/DevicesPage' import OperationsPage from './pages/operations/OperationsPage' -import CalendarPage from './pages/CalendarPage' +import CalendarPage from './pages/calendar/CalendarPage' import DocumentsPage from './pages/DocumentsPage' import ReportsPage from './pages/ReportsPage' import SettingsPage from './pages/settings/SettingsPage' @@ -23,6 +23,8 @@ import MilestoneDevicesPage from './pages/operations/milestone-devices/Milestone import { hasRight } from './components/permissions' import ChatPage from './pages/chat/ChatPage' import { recordNavigation } from './utils/activityLog' +import { syncExistingPushSubscription } from './utils/pushNotifications' +import { notifyNewMessage } from './utils/titleNotifier' const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080' @@ -78,7 +80,11 @@ function resolveNotificationImage(value: unknown) { } try { - return new URL(image, API_URL).toString() + const apiBaseURL = new URL( + `${API_URL.replace(/\/$/, '')}/`, + window.location.origin, + ) + return new URL(image.replace(/^\//, ''), apiBaseURL).toString() } catch { return new URL('/favicon.svg', window.location.origin).toString() } @@ -157,6 +163,12 @@ function App() { recordNavigation(location.pathname) }, [location.pathname]) + useEffect(() => { + if (currentUserId) { + void syncExistingPushSubscription().catch(() => undefined) + } + }, [currentUserId]) + useEffect(() => { if (!currentUserId) { setNotifications([]) @@ -180,9 +192,6 @@ function App() { if (notification.entityType === 'chat') { const data = getNotificationData(notification) const senderAvatar = resolveNotificationImage(data.senderAvatar) - const desktopIcon = - senderAvatar || - new URL('/favicon.svg', window.location.origin).toString() const senderName = typeof data.senderName === 'string' ? data.senderName : notification.title const isChannel = data.conversationType === 'channel' @@ -190,30 +199,6 @@ function App() { ? `/chat/channel/${notification.entityId}` : `/chat/${notification.entityId}` - if ( - notification.desktop && - 'Notification' in window && - Notification.permission === 'granted' - ) { - try { - const desktopNotification = new Notification(notification.title, { - body: notification.message, - icon: desktopIcon, - badge: desktopIcon, - tag: `chat-${notification.entityId}`, - requireInteraction: true, - }) - - desktopNotification.onclick = () => { - window.focus() - navigate(chatPath) - desktopNotification.close() - } - } catch { - // Das sichtbare Browser-Popup darunter bleibt als Fallback erhalten. - } - } - if ( notification.inAppEnabled !== false && document.visibilityState === 'visible' @@ -252,6 +237,10 @@ function App() { }) } + if (document.visibilityState !== 'visible') { + notifyNewMessage() + } + playNotificationSound(notification.soundName) return } diff --git a/frontend/src/components/Modal.tsx b/frontend/src/components/Modal.tsx index 00fe8f4..ce800ce 100644 --- a/frontend/src/components/Modal.tsx +++ b/frontend/src/components/Modal.tsx @@ -28,6 +28,7 @@ type ModalProps = { children?: ReactNode panelClassName?: string + backdropClassName?: string zIndexClassName?: string } @@ -35,6 +36,25 @@ function classNames(...classes: Array) { return classes.filter(Boolean).join(' ') } +export function ModalTitle({ + children, + className, +}: { + children: ReactNode + className?: string +}) { + return ( + + {children} + + ) +} + export default function Modal({ open, setOpen, @@ -46,6 +66,7 @@ export default function Modal({ onConfirm, children, panelClassName, + backdropClassName, zIndexClassName = 'z-[9999]', }: ModalProps) { const isSuccess = variant === 'success-single' || variant === 'success-wide' @@ -82,7 +103,10 @@ export default function Modal({ >
@@ -303,4 +327,4 @@ export default function Modal({
) -} \ No newline at end of file +} diff --git a/frontend/src/components/calendar/Calendar.tsx b/frontend/src/components/calendar/Calendar.tsx new file mode 100644 index 0000000..922ad13 --- /dev/null +++ b/frontend/src/components/calendar/Calendar.tsx @@ -0,0 +1,1251 @@ +import { + ChevronLeftIcon, + ChevronRightIcon, + ClockIcon, + MapPinIcon, + PlusIcon, +} from '@heroicons/react/20/solid' +import { useRef, useState, type PointerEvent } from 'react' +import ButtonGroup from '../ButtonGroup' +import { + timeToMinutes, + workingHoursForDate, + type CalendarCoreWorkingHours, +} from './calendarSettings' +import { toDateKey } from './dateUtils' + +export type CalendarView = 'month' | 'week' | 'day' | 'year' +export type CalendarEventColor = + | 'indigo' + | 'blue' + | 'emerald' + | 'amber' + | 'rose' + | 'slate' + +export type CalendarEvent = { + id: string + title: string + start: string + end: string + allDay: boolean + location: string + description: string + color: CalendarEventColor +} + +type CalendarProps = { + events: CalendarEvent[] + coreWorkingHours: CalendarCoreWorkingHours + selectedDate: Date + view: CalendarView + onSelectedDateChange: (date: Date) => void + onViewChange: (view: CalendarView) => void + onCreateEvent: (start: Date, end?: Date, allDay?: boolean) => void + onEditEvent: (event: CalendarEvent) => void +} + +const weekdayLabels = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] + +const viewLabels: Record = { + day: 'Tag', + week: 'Woche', + month: 'Monat', + year: 'Jahr', +} + +const viewOptions = (Object.keys(viewLabels) as CalendarView[]).map((view) => ({ + id: view, + label: viewLabels[view], +})) + +const eventColors: Record< + CalendarEventColor, + { card: string; dot: string; text: string } +> = { + indigo: { + card: + 'bg-indigo-50 text-indigo-700 ring-indigo-200 hover:bg-indigo-100 dark:bg-indigo-500/15 dark:text-indigo-200 dark:ring-indigo-400/20 dark:hover:bg-indigo-500/25', + dot: 'bg-indigo-500', + text: 'text-indigo-600 dark:text-indigo-300', + }, + blue: { + card: + 'bg-blue-50 text-blue-700 ring-blue-200 hover:bg-blue-100 dark:bg-blue-500/15 dark:text-blue-200 dark:ring-blue-400/20 dark:hover:bg-blue-500/25', + dot: 'bg-blue-500', + text: 'text-blue-600 dark:text-blue-300', + }, + emerald: { + card: + 'bg-emerald-50 text-emerald-700 ring-emerald-200 hover:bg-emerald-100 dark:bg-emerald-500/15 dark:text-emerald-200 dark:ring-emerald-400/20 dark:hover:bg-emerald-500/25', + dot: 'bg-emerald-500', + text: 'text-emerald-600 dark:text-emerald-300', + }, + amber: { + card: + 'bg-amber-50 text-amber-800 ring-amber-200 hover:bg-amber-100 dark:bg-amber-500/15 dark:text-amber-200 dark:ring-amber-400/20 dark:hover:bg-amber-500/25', + dot: 'bg-amber-500', + text: 'text-amber-600 dark:text-amber-300', + }, + rose: { + card: + 'bg-rose-50 text-rose-700 ring-rose-200 hover:bg-rose-100 dark:bg-rose-500/15 dark:text-rose-200 dark:ring-rose-400/20 dark:hover:bg-rose-500/25', + dot: 'bg-rose-500', + text: 'text-rose-600 dark:text-rose-300', + }, + slate: { + card: + 'bg-slate-100 text-slate-700 ring-slate-200 hover:bg-slate-200 dark:bg-white/10 dark:text-slate-200 dark:ring-white/10 dark:hover:bg-white/15', + dot: 'bg-slate-500', + text: 'text-slate-600 dark:text-slate-300', + }, +} + +function classNames(...classes: Array) { + return classes.filter(Boolean).join(' ') +} + +function startOfDay(date: Date) { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()) +} + +function addDays(date: Date, amount: number) { + const next = new Date(date) + next.setDate(next.getDate() + amount) + return startOfDay(next) +} + +function addMonths(date: Date, amount: number) { + const next = new Date(date.getFullYear(), date.getMonth() + amount, 1) + return startOfDay(next) +} + +function addYears(date: Date, amount: number) { + return new Date(date.getFullYear() + amount, date.getMonth(), date.getDate()) +} + +function startOfWeek(date: Date) { + const normalized = startOfDay(date) + const mondayOffset = (normalized.getDay() + 6) % 7 + return addDays(normalized, -mondayOffset) +} + +function isSameDay(left: Date, right: Date) { + return toDateKey(left) === toDateKey(right) +} + +function isToday(date: Date) { + return isSameDay(date, new Date()) +} + +function eventsForDay(events: CalendarEvent[], date: Date) { + const dayStart = startOfDay(date) + const dayEnd = addDays(dayStart, 1) + + return events + .filter((event) => { + const eventStart = new Date(event.start) + const eventEnd = new Date(event.end) + return eventStart < dayEnd && eventEnd > dayStart + }) + .sort( + (left, right) => + Number(right.allDay) - Number(left.allDay) || + new Date(left.start).getTime() - new Date(right.start).getTime(), + ) +} + +function formatTime(value: string | Date) { + return new Intl.DateTimeFormat('de-DE', { + hour: '2-digit', + minute: '2-digit', + }).format(typeof value === 'string' ? new Date(value) : value) +} + +function dateAtMinutes(date: Date, minutes: number) { + const result = startOfDay(date) + result.setMinutes(minutes) + return result +} + +function addMinutes(date: Date, minutes: number) { + return new Date(date.getTime() + minutes * 60_000) +} + +function normalizeTimeRange(anchor: Date, focus: Date) { + const start = anchor <= focus ? anchor : focus + const lastSlot = anchor <= focus ? focus : anchor + return { + start, + end: addMinutes(lastSlot, 30), + } +} + +function formatSlotTime(minutes: number) { + return `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String( + minutes % 60, + ).padStart(2, '0')}` +} + +function titleForView(date: Date, view: CalendarView) { + if (view === 'day') { + return new Intl.DateTimeFormat('de-DE', { + weekday: 'long', + day: '2-digit', + month: 'long', + year: 'numeric', + }).format(date) + } + + if (view === 'week') { + const first = startOfWeek(date) + const last = addDays(first, 6) + const firstLabel = new Intl.DateTimeFormat('de-DE', { + day: '2-digit', + month: first.getMonth() === last.getMonth() ? undefined : 'short', + }).format(first) + const lastLabel = new Intl.DateTimeFormat('de-DE', { + day: '2-digit', + month: 'short', + year: 'numeric', + }).format(last) + return `${firstLabel} - ${lastLabel}` + } + + return new Intl.DateTimeFormat('de-DE', { + month: view === 'month' ? 'long' : undefined, + year: 'numeric', + }).format(date) +} + +function MonthView({ + events, + selectedDate, + onSelectedDateChange, + onCreateEvent, + onEditEvent, +}: Omit) { + const monthStart = new Date( + selectedDate.getFullYear(), + selectedDate.getMonth(), + 1, + ) + const gridStart = startOfWeek(monthStart) + const days = Array.from({ length: 42 }, (_, index) => + addDays(gridStart, index), + ) + const selectedEvents = eventsForDay(events, selectedDate) + + const gridRef = useRef(null) + const dragRef = useRef<{ + pointerId: number + anchorIndex: number + focusIndex: number + } | null>(null) + const [daySelection, setDaySelection] = useState<{ + startIndex: number + endIndex: number + } | null>(null) + const [hoveredIndex, setHoveredIndex] = useState(null) + + function getDayIndexFromPointer( + clientX: number, + clientY: number, + clampToGrid = false, + ) { + const grid = gridRef.current + if (!grid) { + return null + } + + const bounds = grid.getBoundingClientRect() + const rawX = clientX - bounds.left + const rawY = clientY - bounds.top + + if ( + !clampToGrid && + (rawX < 0 || rawX >= bounds.width || rawY < 0 || rawY >= bounds.height) + ) { + return null + } + + const relativeX = Math.min(bounds.width - 1, Math.max(0, rawX)) + const relativeY = Math.min(bounds.height - 1, Math.max(0, rawY)) + const column = Math.min(6, Math.floor((relativeX / bounds.width) * 7)) + const row = Math.min(5, Math.floor((relativeY / bounds.height) * 6)) + + return row * 7 + column + } + + function handleGridPointerDown(event: PointerEvent) { + if (event.button !== 0) { + return + } + + if ( + event.target instanceof HTMLElement && + event.target.closest('[data-calendar-event], [data-calendar-skip-drag]') + ) { + return + } + + const index = getDayIndexFromPointer(event.clientX, event.clientY) + if (index === null || !gridRef.current) { + return + } + + event.preventDefault() + dragRef.current = { + pointerId: event.pointerId, + anchorIndex: index, + focusIndex: index, + } + setHoveredIndex(null) + setDaySelection({ startIndex: index, endIndex: index }) + gridRef.current.setPointerCapture(event.pointerId) + } + + function handleGridPointerMove(event: PointerEvent) { + const drag = dragRef.current + + if (drag) { + const index = getDayIndexFromPointer(event.clientX, event.clientY, true) + if (index === null || index === drag.focusIndex) { + return + } + + drag.focusIndex = index + setDaySelection({ + startIndex: Math.min(drag.anchorIndex, index), + endIndex: Math.max(drag.anchorIndex, index), + }) + return + } + + if (event.pointerType !== 'mouse') { + return + } + + const index = getDayIndexFromPointer(event.clientX, event.clientY) + setHoveredIndex((current) => (current === index ? current : index)) + } + + function finishGridSelection(event: PointerEvent) { + const drag = dragRef.current + if (!drag) { + return + } + + const index = + getDayIndexFromPointer(event.clientX, event.clientY, true) ?? + drag.focusIndex + const startIndex = Math.min(drag.anchorIndex, index) + const endIndex = Math.max(drag.anchorIndex, index) + + if (gridRef.current?.hasPointerCapture(drag.pointerId)) { + gridRef.current.releasePointerCapture(drag.pointerId) + } + + dragRef.current = null + setDaySelection(null) + + if (startIndex === endIndex) { + onSelectedDateChange(days[startIndex]) + onCreateEvent(days[startIndex]) + return + } + + onSelectedDateChange(days[startIndex]) + onCreateEvent(days[startIndex], days[endIndex], true) + } + + function cancelGridSelection() { + const drag = dragRef.current + + if (drag && gridRef.current?.hasPointerCapture(drag.pointerId)) { + gridRef.current.releasePointerCapture(drag.pointerId) + } + + dragRef.current = null + setDaySelection(null) + } + + return ( +
+
+ {weekdayLabels.map((weekday) => ( +
+ {weekday} +
+ ))} +
+ +
{ + if (!dragRef.current) { + setHoveredIndex(null) + } + }} + className="grid min-h-[32rem] flex-1 grid-cols-7 grid-rows-6 gap-px bg-gray-200 select-none dark:bg-white/10" + > + {days.map((day, index) => { + const dayEvents = eventsForDay(events, day) + const currentMonth = day.getMonth() === selectedDate.getMonth() + const selected = isSameDay(day, selectedDate) + const inSelection = + daySelection !== null && + index >= daySelection.startIndex && + index <= daySelection.endIndex + + return ( +
{ + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + onSelectedDateChange(day) + onCreateEvent(day) + } + }} + > + {inSelection && ( +
+ ) + })} +
+ +
+
+

+ {new Intl.DateTimeFormat('de-DE', { + weekday: 'long', + day: '2-digit', + month: 'long', + }).format(selectedDate)} +

+ +
+ {selectedEvents.length === 0 ? ( +

+ Keine Termine an diesem Tag. +

+ ) : ( +
    + {selectedEvents.map((event) => ( +
  1. + +
  2. + ))} +
+ )} +
+
+ ) +} + +function EventListButton({ + event, + onClick, +}: { + event: CalendarEvent + onClick: (event: CalendarEvent) => void +}) { + return ( + + ) +} + +function TimeGridView({ + events, + coreWorkingHours, + selectedDate, + days, + onSelectedDateChange, + onCreateEvent, + onEditEvent, +}: Omit & { days: Date[] }) { + const timeGridRef = useRef(null) + const interactionRef = useRef<{ + pointerId: number + anchor: Date + focus: Date + } | null>(null) + const [hoveredSlot, setHoveredSlot] = useState<{ + dayKey: string + minutes: number + } | null>(null) + const [timeSelection, setTimeSelection] = useState<{ + anchor: Date + focus: Date + } | null>(null) + const hours = Array.from({ length: 24 }, (_, index) => index) + const allDayEvents = days.flatMap((day) => + eventsForDay(events, day).filter((event) => event.allDay), + ) + const selectedRange = timeSelection + ? normalizeTimeRange(timeSelection.anchor, timeSelection.focus) + : null + + function getSlotFromPointer( + clientX: number, + clientY: number, + clampToGrid = false, + ) { + const grid = timeGridRef.current + if (!grid) { + return null + } + + const bounds = grid.getBoundingClientRect() + const timeGutterWidth = 56 + const daysWidth = bounds.width - timeGutterWidth + const rawX = clientX - bounds.left - timeGutterWidth + const rawY = clientY - bounds.top + + if ( + !clampToGrid && + (rawX < 0 || rawX >= daysWidth || rawY < 0 || rawY >= bounds.height) + ) { + return null + } + + const relativeX = Math.min(daysWidth - 1, Math.max(0, rawX)) + const relativeY = Math.min(bounds.height - 1, Math.max(0, rawY)) + const dayIndex = Math.min( + days.length - 1, + Math.floor((relativeX / daysWidth) * days.length), + ) + const minutes = + Math.min(47, Math.floor((relativeY / bounds.height) * 48)) * 30 + + return dateAtMinutes(days[dayIndex], minutes) + } + + function handleTimeGridPointerDown(event: PointerEvent) { + if (event.button !== 0) { + return + } + + if ( + event.target instanceof HTMLElement && + event.target.closest('[data-calendar-event]') + ) { + return + } + + const slot = getSlotFromPointer(event.clientX, event.clientY) + if (!slot || !timeGridRef.current) { + return + } + + event.preventDefault() + interactionRef.current = { + pointerId: event.pointerId, + anchor: slot, + focus: slot, + } + setHoveredSlot(null) + setTimeSelection({ anchor: slot, focus: slot }) + timeGridRef.current.setPointerCapture(event.pointerId) + } + + function handleTimeGridPointerMove(event: PointerEvent) { + const interaction = interactionRef.current + const slot = getSlotFromPointer( + event.clientX, + event.clientY, + Boolean(interaction), + ) + + if (!slot) { + setHoveredSlot(null) + return + } + + if (interaction) { + interaction.focus = slot + setTimeSelection({ + anchor: interaction.anchor, + focus: slot, + }) + return + } + + setHoveredSlot({ + dayKey: toDateKey(slot), + minutes: slot.getHours() * 60 + slot.getMinutes(), + }) + } + + function finishTimeGridSelection(event: PointerEvent) { + const interaction = interactionRef.current + if (!interaction) { + return + } + + const focus = + getSlotFromPointer(event.clientX, event.clientY, true) ?? + interaction.focus + const range = normalizeTimeRange(interaction.anchor, focus) + + if (timeGridRef.current?.hasPointerCapture(interaction.pointerId)) { + timeGridRef.current.releasePointerCapture(interaction.pointerId) + } + + interactionRef.current = null + setTimeSelection(null) + setHoveredSlot(null) + onSelectedDateChange(range.start) + onCreateEvent(range.start, range.end) + } + + function cancelTimeGridSelection() { + const interaction = interactionRef.current + + if ( + interaction && + timeGridRef.current?.hasPointerCapture(interaction.pointerId) + ) { + timeGridRef.current.releasePointerCapture(interaction.pointerId) + } + + interactionRef.current = null + setTimeSelection(null) + setHoveredSlot(null) + } + + return ( +
+
+
+ {days.map((day) => ( + + ))} +
+ + {allDayEvents.length > 0 && ( +
+
+ Ganz +
+ {days.map((day) => ( +
+ {eventsForDay(events, day) + .filter((event) => event.allDay) + .map((event) => ( + + ))} +
+ ))} +
+ )} + +
+
{ + if (!interactionRef.current) { + setHoveredSlot(null) + } + }} + className="grid w-full min-w-max" + style={{ + gridTemplateColumns: `3.5rem repeat(${days.length}, minmax(7rem, 1fr))`, + }} + > +
+ {hours.map((hour) => ( +
+ {String(hour).padStart(2, '0')}:00 +
+ ))} +
+ + {days.map((day) => { + const timedEvents = eventsForDay(events, day).filter( + (event) => !event.allDay, + ) + const coreHours = workingHoursForDate(coreWorkingHours, day) + const coreStartMinutes = coreHours + ? timeToMinutes(coreHours.start) + : 0 + const coreEndMinutes = coreHours + ? timeToMinutes(coreHours.end) + : 0 + const dayStart = startOfDay(day) + const dayEnd = addDays(dayStart, 1) + const selectionStart = + selectedRange && selectedRange.end > dayStart + ? new Date( + Math.max( + selectedRange.start.getTime(), + dayStart.getTime(), + ), + ) + : null + const selectionEnd = + selectedRange && selectedRange.start < dayEnd + ? new Date( + Math.min(selectedRange.end.getTime(), dayEnd.getTime()), + ) + : null + const hasSelection = + selectionStart && + selectionEnd && + selectionEnd > selectionStart + const selectionStartMinutes = selectionStart + ? (selectionStart.getTime() - dayStart.getTime()) / 60_000 + : 0 + const selectionDurationMinutes = + selectionStart && selectionEnd + ? (selectionEnd.getTime() - selectionStart.getTime()) / 60_000 + : 0 + + return ( +
+ {coreHours?.enabled && + coreEndMinutes > coreStartMinutes && ( +
+ )} + + {hasSelection && ( + + )} + {!timeSelection && + hoveredSlot?.dayKey === toDateKey(day) && ( + + )} + {timedEvents.map((event) => { + const eventStart = new Date(event.start) + const eventEnd = new Date(event.end) + const segmentStart = new Date( + Math.max(eventStart.getTime(), dayStart.getTime()), + ) + const segmentEnd = new Date( + Math.min(eventEnd.getTime(), dayEnd.getTime()), + ) + const startMinutes = + (segmentStart.getTime() - dayStart.getTime()) / 60_000 + const durationMinutes = Math.max( + 30, + (segmentEnd.getTime() - segmentStart.getTime()) / 60_000, + ) + + return ( + + ) + })} +
+ ) + })} +
+
+
+ ) +} + +function YearView({ + events, + selectedDate, + onSelectedDateChange, +}: Pick< + CalendarProps, + 'events' | 'selectedDate' | 'onSelectedDateChange' +>) { + const months = Array.from( + { length: 12 }, + (_, month) => new Date(selectedDate.getFullYear(), month, 1), + ) + + return ( +
+
+ {months.map((month) => { + const gridStart = startOfWeek(month) + const days = Array.from({ length: 42 }, (_, index) => + addDays(gridStart, index), + ) + + return ( +
+

+ {new Intl.DateTimeFormat('de-DE', { month: 'long' }).format( + month, + )} +

+
+ {weekdayLabels.map((weekday) => ( +
{weekday.slice(0, 1)}
+ ))} +
+
+ {days.map((day) => { + const inMonth = day.getMonth() === month.getMonth() + const dayEvents = eventsForDay(events, day) + const selected = isSameDay(day, selectedDate) + + return ( + + ) + })} +
+
+ ) + })} +
+
+ ) +} + +export default function Calendar({ + events, + coreWorkingHours, + selectedDate, + view, + onSelectedDateChange, + onViewChange, + onCreateEvent, + onEditEvent, +}: CalendarProps) { + function move(amount: number) { + if (view === 'day') { + onSelectedDateChange(addDays(selectedDate, amount)) + return + } + if (view === 'week') { + onSelectedDateChange(addDays(selectedDate, amount * 7)) + return + } + if (view === 'month') { + onSelectedDateChange(addMonths(selectedDate, amount)) + return + } + onSelectedDateChange(addYears(selectedDate, amount)) + } + + const weekDays = Array.from({ length: 7 }, (_, index) => + addDays(startOfWeek(selectedDate), index), + ) + + return ( +
+
+
+

+ Kalender +

+

+ {titleForView(selectedDate, view)} +

+
+ +
+
+ + + +
+ + onViewChange(nextView as CalendarView)} + /> + + +
+
+ + {view === 'month' && ( + + )} + {view === 'week' && ( + + )} + {view === 'day' && ( + + )} + {view === 'year' && ( + + )} +
+ ) +} diff --git a/frontend/src/components/calendar/calendarSettings.ts b/frontend/src/components/calendar/calendarSettings.ts new file mode 100644 index 0000000..9a6d12a --- /dev/null +++ b/frontend/src/components/calendar/calendarSettings.ts @@ -0,0 +1,85 @@ +export const calendarWeekdays = [ + { key: 'monday', label: 'Montag', jsDay: 1 }, + { key: 'tuesday', label: 'Dienstag', jsDay: 2 }, + { key: 'wednesday', label: 'Mittwoch', jsDay: 3 }, + { key: 'thursday', label: 'Donnerstag', jsDay: 4 }, + { key: 'friday', label: 'Freitag', jsDay: 5 }, + { key: 'saturday', label: 'Samstag', jsDay: 6 }, + { key: 'sunday', label: 'Sonntag', jsDay: 0 }, +] as const + +export type CalendarWeekdayKey = (typeof calendarWeekdays)[number]['key'] + +export type CalendarWorkingHoursDay = { + enabled: boolean + start: string + end: string +} + +export type CalendarCoreWorkingHours = Record< + CalendarWeekdayKey, + CalendarWorkingHoursDay +> + +export type CalendarSettings = { + coreWorkingHours: CalendarCoreWorkingHours + updatedAt?: string +} + +const workingDay: CalendarWorkingHoursDay = { + enabled: true, + start: '08:00', + end: '17:00', +} + +const dayOff: CalendarWorkingHoursDay = { + enabled: false, + start: '08:00', + end: '17:00', +} + +export function createDefaultCalendarSettings(): CalendarSettings { + return { + coreWorkingHours: { + monday: { ...workingDay }, + tuesday: { ...workingDay }, + wednesday: { ...workingDay }, + thursday: { ...workingDay }, + friday: { ...workingDay }, + saturday: { ...dayOff }, + sunday: { ...dayOff }, + }, + } +} + +export function normalizeCalendarSettings( + value: Partial | null | undefined, +): CalendarSettings { + const defaults = createDefaultCalendarSettings() + + return { + coreWorkingHours: Object.fromEntries( + calendarWeekdays.map(({ key }) => [ + key, + { + ...defaults.coreWorkingHours[key], + ...value?.coreWorkingHours?.[key], + }, + ]), + ) as CalendarCoreWorkingHours, + updatedAt: value?.updatedAt, + } +} + +export function timeToMinutes(value: string) { + const [hours, minutes] = value.split(':').map(Number) + return hours * 60 + minutes +} + +export function workingHoursForDate( + workingHours: CalendarCoreWorkingHours, + date: Date, +) { + const weekday = calendarWeekdays.find(({ jsDay }) => jsDay === date.getDay()) + return weekday ? workingHours[weekday.key] : undefined +} diff --git a/frontend/src/components/calendar/dateUtils.ts b/frontend/src/components/calendar/dateUtils.ts new file mode 100644 index 0000000..7aceb16 --- /dev/null +++ b/frontend/src/components/calendar/dateUtils.ts @@ -0,0 +1,6 @@ +export function toDateKey(date: Date) { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 1e17b47..36cc40f 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -7,8 +7,12 @@ import './index.css' import App from './App.tsx' import { NotificationProvider } from './components/Notifications.tsx' import { initActivityLog } from './utils/activityLog.ts' +import { registerServiceWorker } from './utils/pushNotifications.ts' initActivityLog() +void registerServiceWorker().catch(() => { + // Die App bleibt ohne Service Worker nutzbar, nur Hintergrund-Push entfällt. +}) createRoot(document.getElementById('root')!).render( diff --git a/frontend/src/pages/CalendarPage.tsx b/frontend/src/pages/CalendarPage.tsx deleted file mode 100644 index 0337110..0000000 --- a/frontend/src/pages/CalendarPage.tsx +++ /dev/null @@ -1,11 +0,0 @@ -// frontend/src/pages/CalendarPage.tsx - -export default function CalendarPage() { - return ( -
-

- Kalender -

-
- ) -} \ No newline at end of file diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 82fa0fb..585f6fb 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -7,6 +7,7 @@ import Card from '../components/Card' import Checkbox from '../components/Checkbox' import type { User } from '../components/types' import { getCurrentSessionInfo } from '../utils/sessionInfo' +import { clearActivityLog } from '../utils/activityLog' const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080' @@ -90,6 +91,10 @@ export default function LoginPage({ const data = await response.json().catch(() => null) const user = data?.user as User | undefined + // Erfolgreicher Login: clientseitiges Aktivitätsprotokoll leeren, damit es + // (wie das serverseitige User-Protokoll) nach dem Login leer startet. + clearActivityLog() + if (user) { onLogin?.(user) } diff --git a/frontend/src/pages/administration/AdministrationPage.tsx b/frontend/src/pages/administration/AdministrationPage.tsx index 94eb696..11bfcd0 100644 --- a/frontend/src/pages/administration/AdministrationPage.tsx +++ b/frontend/src/pages/administration/AdministrationPage.tsx @@ -2,18 +2,18 @@ import { useLocation, Navigate } from 'react-router' import { + CalendarDaysIcon, ChatBubbleLeftRightIcon, - HashtagIcon, UserGroupIcon, UsersIcon, VideoCameraIcon, } from '@heroicons/react/20/solid' import Tabs from '../../components/Tabs' import UsersAdministration from './users/UsersAdministration' -import TeamsAdministration from './teams/TeamsAdministration' import FeedbackAdministration from './feedback/FeedbackAdministration' import Milestone from './milestone/Milestone' -import ChannelsAdministration from './channels/ChannelsAdministration' +import ChatsAdministration from './chats/ChatsAdministration' +import CalendarAdministration from './calendar/CalendarAdministration' export default function AdministrationPage() { const location = useLocation() @@ -30,16 +30,10 @@ export default function AdministrationPage() { current: section === 'benutzer', }, { - name: 'Teams', - href: '/administration/teams', + name: 'Teams & Chats', + href: '/administration/chats/teams', icon: UserGroupIcon, - current: section === 'teams', - }, - { - name: 'Channels', - href: '/administration/channels', - icon: HashtagIcon, - current: section === 'channels', + current: section === 'chats', }, { name: 'Milestone', @@ -47,6 +41,12 @@ export default function AdministrationPage() { icon: VideoCameraIcon, current: section === 'milestone', }, + { + name: 'Kalender', + href: '/administration/kalender', + icon: CalendarDaysIcon, + current: section === 'kalender', + }, { name: 'Feedback', href: '/administration/feedback', @@ -55,7 +55,19 @@ export default function AdministrationPage() { }, ] - if (!['benutzer', 'teams', 'channels', 'milestone', 'feedback'].includes(section)) { + if (section === 'teams') { + return + } + + if (section === 'channels') { + return + } + + if ( + !['benutzer', 'chats', 'milestone', 'kalender', 'feedback'].includes( + section, + ) + ) { return } @@ -80,14 +92,14 @@ export default function AdministrationPage() {

- Benutzer verwalten, Teams zuweisen und Integrationen konfigurieren. + Benutzer, Kommunikation, Kalender und Integrationen verwalten.

{section === 'benutzer' && } - {section === 'teams' && } - {section === 'channels' && } + {section === 'chats' && } {section === 'milestone' && } + {section === 'kalender' && } {section === 'feedback' && }
diff --git a/frontend/src/pages/administration/calendar/CalendarAdministration.tsx b/frontend/src/pages/administration/calendar/CalendarAdministration.tsx new file mode 100644 index 0000000..48e6774 --- /dev/null +++ b/frontend/src/pages/administration/calendar/CalendarAdministration.tsx @@ -0,0 +1,251 @@ +import { ClockIcon } from '@heroicons/react/20/solid' +import { useEffect, useState, type FormEvent } from 'react' +import Button from '../../../components/Button' +import { useNotifications } from '../../../components/Notifications' +import Switch from '../../../components/Switch' +import { + calendarWeekdays, + createDefaultCalendarSettings, + normalizeCalendarSettings, + timeToMinutes, + type CalendarSettings, +} from '../../../components/calendar/calendarSettings' + +const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080' + +const timeInputClassName = + 'rounded-lg bg-white px-3 py-2 text-sm text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:text-gray-400 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:focus:outline-indigo-500 dark:disabled:bg-white/[0.03] dark:disabled:text-gray-600' + +export default function CalendarAdministration() { + const { showToast, showErrorToast } = useNotifications() + const [settings, setSettings] = useState( + createDefaultCalendarSettings, + ) + const [isLoading, setIsLoading] = useState(true) + const [isSaving, setIsSaving] = useState(false) + const [formError, setFormError] = useState('') + + useEffect(() => { + let ignore = false + + void fetch(`${API_URL}/admin/calendar/settings`, { + credentials: 'include', + }) + .then(async (response) => { + const data = await response.json().catch(() => null) + if (!response.ok) { + throw new Error( + data?.error ?? 'Kalender-Einstellungen konnten nicht geladen werden', + ) + } + + if (!ignore) { + setSettings(normalizeCalendarSettings(data?.settings)) + } + }) + .catch((error) => { + if (!ignore) { + showErrorToast({ + title: 'Kalender-Einstellungen konnten nicht geladen werden', + error, + fallback: 'Kalender-Einstellungen konnten nicht geladen werden', + }) + } + }) + .finally(() => { + if (!ignore) { + setIsLoading(false) + } + }) + + return () => { + ignore = true + } + }, [showErrorToast]) + + async function handleSubmit(event: FormEvent) { + event.preventDefault() + setFormError('') + + for (const weekday of calendarWeekdays) { + const hours = settings.coreWorkingHours[weekday.key] + if ( + hours.enabled && + timeToMinutes(hours.end) <= timeToMinutes(hours.start) + ) { + setFormError( + `${weekday.label}: Das Ende muss nach dem Beginn liegen.`, + ) + return + } + } + + setIsSaving(true) + + try { + const response = await fetch(`${API_URL}/admin/calendar/settings`, { + method: 'PUT', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + coreWorkingHours: settings.coreWorkingHours, + }), + }) + const data = await response.json().catch(() => null) + + if (!response.ok) { + throw new Error( + data?.error ?? 'Kalender-Einstellungen konnten nicht gespeichert werden', + ) + } + + setSettings(normalizeCalendarSettings(data?.settings)) + showToast({ + title: 'Kernarbeitszeiten gespeichert', + message: + 'Die Tages- und Wochenansicht des Kalenders verwendet jetzt die neuen Zeiten.', + variant: 'success', + }) + } catch (error) { + showErrorToast({ + title: 'Kernarbeitszeiten konnten nicht gespeichert werden', + error, + fallback: 'Kernarbeitszeiten konnten nicht gespeichert werden', + }) + } finally { + setIsSaving(false) + } + } + + if (isLoading) { + return ( +
+ Kalender-Einstellungen werden geladen... +
+ ) + } + + return ( +
+
+
+ +
+

+ Kernarbeitszeiten +

+

+ Lege für jeden Wochentag den hervorgehobenen Arbeitszeitraum fest. + Deaktivierte Tage erhalten im Kalender keine Markierung. +

+
+ +
+
+ Wochentag + Aktiv + Beginn + Ende +
+ +
+ {calendarWeekdays.map((weekday) => { + const hours = settings.coreWorkingHours[weekday.key] + + return ( +
+ + {weekday.label} + + + setSettings((current) => ({ + ...current, + coreWorkingHours: { + ...current.coreWorkingHours, + [weekday.key]: { + ...hours, + enabled: event.target.checked, + }, + }, + })) + } + /> + + +
+ ) + })} +
+ +
+ {formError && ( +

+ {formError} +

+ )} + +
+
+
+ ) +} diff --git a/frontend/src/pages/administration/chats/ChatsAdministration.tsx b/frontend/src/pages/administration/chats/ChatsAdministration.tsx new file mode 100644 index 0000000..4ad6965 --- /dev/null +++ b/frontend/src/pages/administration/chats/ChatsAdministration.tsx @@ -0,0 +1,50 @@ +import { Navigate, useLocation } from 'react-router' +import Tabs from '../../../components/Tabs' +import ChannelsAdministration from '../channels/ChannelsAdministration' +import GroupChatsAdministration from '../groups/GroupChatsAdministration' +import TeamsAdministration from '../teams/TeamsAdministration' + +const sections = ['teams', 'gruppen', 'channels'] as const + +export default function ChatsAdministration() { + const location = useLocation() + const section = location.pathname.split('/').filter(Boolean)[2] ?? 'teams' + + if (!sections.includes(section as (typeof sections)[number])) { + return + } + + const tabs = [ + { + name: 'Teams', + href: '/administration/chats/teams', + current: section === 'teams', + }, + { + name: 'Gruppenchats', + href: '/administration/chats/gruppen', + current: section === 'gruppen', + }, + { + name: 'Channels', + href: '/administration/chats/channels', + current: section === 'channels', + }, + ] + + return ( +
+
+ +
+ + {section === 'teams' && } + {section === 'gruppen' && } + {section === 'channels' && } +
+ ) +} diff --git a/frontend/src/pages/administration/groups/GroupChatsAdministration.tsx b/frontend/src/pages/administration/groups/GroupChatsAdministration.tsx new file mode 100644 index 0000000..0513f37 --- /dev/null +++ b/frontend/src/pages/administration/groups/GroupChatsAdministration.tsx @@ -0,0 +1,529 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ChangeEvent, + type FormEvent, +} from 'react' +import { + MagnifyingGlassIcon, + PhotoIcon, + TrashIcon, + UserGroupIcon, +} from '@heroicons/react/20/solid' +import Avatar from '../../../components/Avatar' +import Button from '../../../components/Button' +import Checkbox from '../../../components/Checkbox' +import LoadingSpinner from '../../../components/LoadingSpinner' +import { useNotifications } from '../../../components/Notifications' + +const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080' + +type AdminGroupChat = { + id: string + name: string + image: string + ownerId: string + userIds: string[] + createdAt: string + updatedAt: string +} + +type AdminGroupUser = { + id: string + username: string + displayName: string + email: string + unit: string +} + +type GroupDraft = { + id: string + name: string + image: string + ownerId: string + userIds: string[] +} + +const inputClassName = + 'block w-full rounded-md bg-white px-3 py-1.5 text-sm text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:focus:outline-indigo-500' + +async function readApiError(response: Response, fallback: string) { + const data = await response.json().catch(() => null) + return data?.error ?? fallback +} + +function userDisplayName(user: AdminGroupUser) { + return user.displayName || user.username || user.email +} + +function groupToDraft(group: AdminGroupChat): GroupDraft { + return { + id: group.id, + name: group.name, + image: group.image, + ownerId: group.ownerId, + userIds: group.userIds ?? [], + } +} + +export default function GroupChatsAdministration() { + const { showToast, showErrorToast } = useNotifications() + const [groups, setGroups] = useState([]) + const [users, setUsers] = useState([]) + const [draft, setDraft] = useState(null) + const [groupSearch, setGroupSearch] = useState('') + const [userSearch, setUserSearch] = useState('') + const [isLoading, setIsLoading] = useState(true) + const [isSaving, setIsSaving] = useState(false) + const [isDeleting, setIsDeleting] = useState(false) + const imageInputRef = useRef(null) + + const loadGroups = useCallback( + async (preferredId?: string) => { + setIsLoading(true) + try { + const response = await fetch(`${API_URL}/admin/group-chats`, { + credentials: 'include', + }) + if (!response.ok) { + throw new Error( + await readApiError( + response, + 'Gruppenchats konnten nicht geladen werden', + ), + ) + } + + const data = await response.json() + const nextGroups = (data.groups ?? []) as AdminGroupChat[] + setGroups(nextGroups) + setUsers(data.users ?? []) + + const selected = + nextGroups.find((group) => group.id === preferredId) ?? + nextGroups[0] ?? + null + setDraft(selected ? groupToDraft(selected) : null) + } catch (error) { + showErrorToast({ + title: 'Gruppenchats konnten nicht geladen werden', + error, + fallback: 'Gruppenchats konnten nicht geladen werden', + }) + } finally { + setIsLoading(false) + } + }, + [showErrorToast], + ) + + useEffect(() => { + const timeoutId = window.setTimeout(() => { + void loadGroups() + }, 0) + + return () => window.clearTimeout(timeoutId) + }, [loadGroups]) + + const filteredGroups = useMemo(() => { + const search = groupSearch.trim().toLowerCase() + if (!search) { + return groups + } + return groups.filter((group) => group.name.toLowerCase().includes(search)) + }, [groupSearch, groups]) + + const filteredUsers = useMemo(() => { + const search = userSearch.trim().toLowerCase() + if (!search) { + return users + } + return users.filter((user) => + [ + user.displayName, + user.username, + user.email, + user.unit, + ] + .join(' ') + .toLowerCase() + .includes(search), + ) + }, [userSearch, users]) + + const owner = users.find((user) => user.id === draft?.ownerId) + + function selectGroup(group: AdminGroupChat) { + setDraft(groupToDraft(group)) + setUserSearch('') + } + + function updateDraft( + key: Key, + value: GroupDraft[Key], + ) { + setDraft((current) => (current ? { ...current, [key]: value } : current)) + } + + function toggleUser(userId: string, checked: boolean) { + if (!draft || userId === draft.ownerId) { + return + } + + updateDraft( + 'userIds', + checked + ? [...new Set([...draft.userIds, userId])] + : draft.userIds.filter((id) => id !== userId), + ) + } + + function handleImageFileChange(event: ChangeEvent) { + const file = event.target.files?.[0] + event.target.value = '' + + if (!file) { + return + } + if (!file.type.startsWith('image/')) { + showToast({ + title: 'Ungültige Bilddatei', + message: 'Bitte wähle eine gültige Bilddatei aus.', + variant: 'error', + }) + return + } + if (file.size > 3 * 1024 * 1024) { + showToast({ + title: 'Gruppenbild zu groß', + message: 'Das Gruppenbild darf maximal 3 MB groß sein.', + variant: 'error', + }) + return + } + + const reader = new FileReader() + reader.onload = () => { + if (typeof reader.result === 'string') { + updateDraft('image', reader.result) + } + } + reader.readAsDataURL(file) + } + + async function saveGroup(event: FormEvent) { + event.preventDefault() + if (!draft) { + return + } + + setIsSaving(true) + try { + const response = await fetch(`${API_URL}/admin/group-chats/${draft.id}`, { + method: 'PUT', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: draft.name, + image: draft.image, + userIds: draft.userIds, + }), + }) + if (!response.ok) { + throw new Error( + await readApiError( + response, + 'Gruppenchat konnte nicht gespeichert werden', + ), + ) + } + + await loadGroups(draft.id) + showToast({ + title: 'Gruppenchat gespeichert', + message: 'Name, Bild und Mitglieder wurden aktualisiert.', + variant: 'success', + }) + } catch (error) { + showErrorToast({ + title: 'Gruppenchat konnte nicht gespeichert werden', + error, + fallback: 'Gruppenchat konnte nicht gespeichert werden', + }) + } finally { + setIsSaving(false) + } + } + + async function deleteGroup() { + if ( + !draft || + !window.confirm( + `Möchtest du den Gruppenchat „${draft.name}“ wirklich löschen? Alle Nachrichten und Anhänge werden dauerhaft gelöscht.`, + ) + ) { + return + } + + setIsDeleting(true) + try { + const response = await fetch(`${API_URL}/admin/group-chats/${draft.id}`, { + method: 'DELETE', + credentials: 'include', + }) + if (!response.ok) { + throw new Error( + await readApiError( + response, + 'Gruppenchat konnte nicht gelöscht werden', + ), + ) + } + + await loadGroups() + showToast({ + title: 'Gruppenchat gelöscht', + message: 'Der Gruppenchat und sein Verlauf wurden dauerhaft gelöscht.', + variant: 'success', + }) + } catch (error) { + showErrorToast({ + title: 'Gruppenchat konnte nicht gelöscht werden', + error, + fallback: 'Gruppenchat konnte nicht gelöscht werden', + }) + } finally { + setIsDeleting(false) + } + } + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (!draft) { + return ( +
+ +

+ Keine freien Gruppenchats +

+

+ Neue Gruppenchats werden im Chat erstellt und können anschließend hier + administriert werden. +

+
+ ) + } + + return ( +
+ + +
+
+

+ Gruppenchat bearbeiten +

+

+ Der Ersteller bleibt Mitglied; weitere Mitglieder können zentral + verwaltet werden. +

+
+ +
+ +
+ +
+

+ Gruppenbild +

+
+ +
+ +
+ + {draft.image && ( + + )} +
+

+ Optional: PNG, JPG, WEBP oder GIF bis maximal 3 MB. +

+
+
+
+ +
+
+
+

+ Mitglieder +

+

+ Ersteller: {owner ? userDisplayName(owner) : 'Unbekannt'} +

+
+
+
+
+ +
+ {filteredUsers.map((user) => { + const isOwner = user.id === draft.ownerId + return ( + + toggleUser(user.id, event.target.checked) + } + label={ + isOwner + ? `${userDisplayName(user)} (Ersteller)` + : userDisplayName(user) + } + description={user.unit || user.email} + /> + ) + })} +
+
+ +
+ + +
+
+
+ ) +} diff --git a/frontend/src/pages/administration/teams/TeamsAdministration.tsx b/frontend/src/pages/administration/teams/TeamsAdministration.tsx index 48b3c0a..0997178 100644 --- a/frontend/src/pages/administration/teams/TeamsAdministration.tsx +++ b/frontend/src/pages/administration/teams/TeamsAdministration.tsx @@ -16,6 +16,7 @@ type AdminTeam = { name: string initial: string href: string + conversationId: string } const inputClassName = @@ -263,7 +264,9 @@ export default function TeamsAdministration() { - {team.href || '#'} + {team.conversationId + ? 'Gruppenchat aktiv' + : 'Gruppenchat wird vorbereitet'} @@ -294,6 +297,11 @@ export default function TeamsAdministration() {
+
+ Für jedes Team wird automatisch ein Gruppenchat mit allen + Teammitgliedern geführt. +
+