teg/backend/presence.go
2026-06-13 10:15:57 +02:00

310 lines
7.0 KiB
Go

package main
import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
)
const presenceStatusSQL = `
CASE
WHEN presence_mode = 'offline'
OR last_seen_at IS NULL
OR last_seen_at < now() - INTERVAL '2 minutes'
THEN 'offline'
WHEN presence_mode = 'away'
OR last_active_at IS NULL
OR last_active_at < now() - make_interval(mins => away_after_minutes)
THEN 'away'
ELSE 'online'
END
`
type presenceHeartbeatRequest struct {
Active bool `json:"active"`
}
type presenceSettingsRequest struct {
Mode string `json:"mode"`
AwayAfterMinutes int `json:"awayAfterMinutes"`
ShowLastSeen bool `json:"showLastSeen"`
}
type presenceStatusRequest struct {
Mode string `json:"mode"`
}
func normalizePresenceMode(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "away":
return "away"
case "offline":
return "offline"
default:
return "online"
}
}
func (s *Server) startPresenceMonitor(ctx context.Context) {
s.refreshPresenceStatuses(ctx, false)
go func() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.refreshPresenceStatuses(ctx, true)
}
}
}()
}
func (s *Server) refreshPresenceStatuses(ctx context.Context, publish bool) {
rows, err := s.db.Query(
ctx,
`
SELECT id::TEXT, `+presenceStatusSQL+`
FROM users
`,
)
if err != nil {
return
}
defer rows.Close()
currentStatuses := map[string]string{}
changedUserIDs := []string{}
for rows.Next() {
var userID string
var status string
if err := rows.Scan(&userID, &status); err != nil {
return
}
currentStatuses[userID] = status
s.presenceMu.Lock()
previousStatus, known := s.presenceStatus[userID]
s.presenceMu.Unlock()
if publish && known && previousStatus != status {
changedUserIDs = append(changedUserIDs, userID)
}
}
if rows.Err() != nil {
return
}
s.presenceMu.Lock()
s.presenceStatus = currentStatuses
s.presenceMu.Unlock()
for _, userID := range changedUserIDs {
user, err := s.getUserByID(ctx, userID)
if err == nil {
s.publishUserProfileEvent("user.presence", user)
}
}
}
func (s *Server) publishPresenceIfChanged(user User) {
s.presenceMu.Lock()
previousStatus, known := s.presenceStatus[user.ID]
s.presenceStatus[user.ID] = user.PresenceStatus
s.presenceMu.Unlock()
if !known || previousStatus != user.PresenceStatus {
s.publishUserProfileEvent("user.presence", user)
}
}
func (s *Server) handlePresence(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
var input presenceHeartbeatRequest
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&input)
}
_, err := s.db.Exec(
r.Context(),
`
UPDATE users
SET
last_seen_at = now(),
last_active_at = CASE
WHEN $2 AND presence_mode = 'online' THEN now()
ELSE last_active_at
END
WHERE id = $1
`,
user.ID,
input.Active,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Präsenz konnte nicht aktualisiert werden")
return
}
updatedUser, err := s.getUserByID(r.Context(), user.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "Präsenz konnte nicht geladen werden")
return
}
s.publishPresenceIfChanged(updatedUser)
writeJSON(w, http.StatusOK, map[string]any{
"presenceStatus": updatedUser.PresenceStatus,
"lastSeenAt": updatedUser.LastSeenAt,
})
}
func (s *Server) handleGetPresenceSettings(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"settings": map[string]any{
"mode": user.PresenceMode,
"awayAfterMinutes": user.AwayAfterMinutes,
"currentStatus": user.PresenceStatus,
"showLastSeen": user.ShowLastSeen,
},
})
}
func (s *Server) handleUpdatePresenceSettings(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
var input presenceSettingsRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
mode := user.PresenceMode
if strings.TrimSpace(input.Mode) != "" {
mode = normalizePresenceMode(input.Mode)
}
if input.AwayAfterMinutes < 1 || input.AwayAfterMinutes > 120 {
writeError(w, http.StatusBadRequest, "Automatische Abwesenheit muss zwischen 1 und 120 Minuten liegen")
return
}
_, err := s.db.Exec(
r.Context(),
`
UPDATE users
SET
presence_mode_updated_at = CASE
WHEN presence_mode IS DISTINCT FROM $2 THEN now()
ELSE presence_mode_updated_at
END,
presence_mode = $2,
away_after_minutes = $3,
show_last_seen = $4,
last_active_at = CASE WHEN $2 = 'online' THEN now() ELSE last_active_at END,
updated_at = now()
WHERE id = $1
`,
user.ID,
mode,
input.AwayAfterMinutes,
input.ShowLastSeen,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Präsenzeinstellungen konnten nicht gespeichert werden")
return
}
updatedUser, err := s.getUserByID(r.Context(), user.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "Präsenzeinstellungen konnten nicht geladen werden")
return
}
s.publishPresenceIfChanged(updatedUser)
writeJSON(w, http.StatusOK, map[string]any{
"settings": map[string]any{
"mode": updatedUser.PresenceMode,
"awayAfterMinutes": updatedUser.AwayAfterMinutes,
"currentStatus": updatedUser.PresenceStatus,
"showLastSeen": updatedUser.ShowLastSeen,
},
"user": updatedUser,
})
}
func (s *Server) handleUpdatePresenceStatus(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
var input presenceStatusRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
mode := strings.ToLower(strings.TrimSpace(input.Mode))
if mode != "online" && mode != "away" && mode != "offline" {
writeError(w, http.StatusBadRequest, "Ungültiger Status")
return
}
_, err := s.db.Exec(
r.Context(),
`
UPDATE users
SET
presence_mode_updated_at = CASE
WHEN presence_mode IS DISTINCT FROM $2 THEN now()
ELSE presence_mode_updated_at
END,
presence_mode = $2,
last_active_at = CASE WHEN $2 = 'online' THEN now() ELSE last_active_at END,
last_seen_at = now(),
updated_at = now()
WHERE id = $1
`,
user.ID,
mode,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Status konnte nicht gespeichert werden")
return
}
updatedUser, err := s.getUserByID(r.Context(), user.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "Status konnte nicht geladen werden")
return
}
s.publishPresenceIfChanged(updatedUser)
writeJSON(w, http.StatusOK, map[string]any{"user": updatedUser})
}