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}) }