310 lines
7.0 KiB
Go
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})
|
|
}
|