package api import ( "encoding/json" "net/http" "sort" "strings" "time" "github.com/ponzischeme89/memby/server/internal/cache" "github.com/ponzischeme89/memby/server/internal/recommend" "github.com/ponzischeme89/memby/server/internal/store" ) type adminOnboardingRating struct { ItemID string `json:"itemId"` Title string `json:"title"` Rating int `json:"rating"` } type adminOnboardingPreferences struct { Completed bool `json:"completed"` Updated bool `json:"updated"` Ratings []adminOnboardingRating `json:"ratings"` Genres []string `json:"genres"` Studios []string `json:"studios"` Actors []string `json:"actors"` Directors []string `json:"directors"` ContentTypes []string `json:"contentTypes"` } type adminMembyAccount struct { ID string `json:"id"` Username string `json:"username"` CreatedAt time.Time `json:"createdAt"` LastSeen time.Time `json:"lastSeen"` Devices []store.MembyDevice `json:"devices"` Recommendations adminOnboardingPreferences `json:"recommendations"` } func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) { accounts, err := s.store.MembyAccounts(r.Context()) if err != nil { s.log.Error("Memby account list failed", "error", err) writeError(w, http.StatusInternalServerError, "could not load Memby accounts") return } allRatingIDs := []string{} preferences := make(map[string]recommend.OnboardingPreferences, len(accounts)) for _, account := range accounts { var pref recommend.OnboardingPreferences _ = json.Unmarshal(account.RecommendationPreferences, &pref) preferences[account.ID] = pref for id := range pref.Ratings { allRatingIDs = append(allRatingIDs, id) } } titles := map[string]string{} if raws, loadErr := s.store.LibraryItemsByID(r.Context(), allRatingIDs); loadErr == nil { for _, item := range recommend.Decode(raws) { titles[item.ID] = item.Name } } result := make([]adminMembyAccount, 0, len(accounts)) for _, account := range accounts { pref := preferences[account.ID] ratings := make([]adminOnboardingRating, 0, len(pref.Ratings)) for itemID, rating := range pref.Ratings { title := titles[itemID] if title == "" { title = itemID } ratings = append(ratings, adminOnboardingRating{ItemID: itemID, Title: title, Rating: rating}) } sort.Slice(ratings, func(i, j int) bool { if ratings[i].Rating != ratings[j].Rating { return ratings[i].Rating > ratings[j].Rating } return strings.ToLower(ratings[i].Title) < strings.ToLower(ratings[j].Title) }) result = append(result, adminMembyAccount{ ID: account.ID, Username: account.Username, CreatedAt: account.CreatedAt, LastSeen: account.LastSeen, Devices: account.Devices, Recommendations: adminOnboardingPreferences{ Completed: pref.Completed, Updated: len(account.RecommendationPreferences) > 2, Ratings: ratings, Genres: nonNilStrings(pref.Genres), Studios: nonNilStrings(pref.Studios), Actors: nonNilStrings(pref.Actors), Directors: nonNilStrings(pref.Directors), ContentTypes: nonNilStrings(pref.ContentTypes), }, }) } writeJSON(w, http.StatusOK, map[string]any{"accounts": result}) } func (s *Server) handleAdminRenameDevice(w http.ResponseWriter, r *http.Request) { userID, deviceID := strings.TrimSpace(r.PathValue("userID")), strings.TrimSpace(r.PathValue("deviceID")) var req renameDeviceRequest if userID == "" || deviceID == "" || json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<10)).Decode(&req) != nil { writeError(w, http.StatusBadRequest, "user, device and name are required") return } req.DeviceName = strings.TrimSpace(req.DeviceName) if req.DeviceName == "" || len([]rune(req.DeviceName)) > 80 { writeError(w, http.StatusBadRequest, "device name must be between 1 and 80 characters") return } if err := s.store.RenameUserDevice(r.Context(), userID, deviceID, req.DeviceName); err == store.ErrNotFound { writeError(w, http.StatusNotFound, "Memby device not found") return } else if err != nil { writeError(w, http.StatusInternalServerError, "could not rename Memby device") return } w.WriteHeader(http.StatusNoContent) } func (s *Server) handleAdminDeleteDevice(w http.ResponseWriter, r *http.Request) { userID, deviceID := strings.TrimSpace(r.PathValue("userID")), strings.TrimSpace(r.PathValue("deviceID")) if userID == "" || deviceID == "" { writeError(w, http.StatusBadRequest, "user and device are required") return } hash, err := s.store.DeleteUserDevice(r.Context(), userID, deviceID) if err == store.ErrNotFound { writeError(w, http.StatusNotFound, "Memby device not found") return } if err != nil { writeError(w, http.StatusInternalServerError, "could not sign out Memby device") return } if s.cache != nil { _ = s.cache.Delete(r.Context(), cache.SessionKey(hexHash(hash))) } w.WriteHeader(http.StatusNoContent) } func (s *Server) handleAdminDeleteAccount(w http.ResponseWriter, r *http.Request) { userID := strings.TrimSpace(r.PathValue("userID")) if userID == "" { writeError(w, http.StatusBadRequest, "user is required") return } hashes, err := s.store.DeleteUserSessions(r.Context(), userID) if err == store.ErrNotFound { writeError(w, http.StatusNotFound, "Memby account not found") return } if err != nil { writeError(w, http.StatusInternalServerError, "could not revoke Memby account access") return } if s.cache != nil { keys := make([]string, 0, len(hashes)) for _, hash := range hashes { keys = append(keys, cache.SessionKey(hexHash(hash))) } _ = s.cache.Delete(r.Context(), keys...) _ = s.cache.InvalidateUser(r.Context(), userID) } w.WriteHeader(http.StatusNoContent) } func (s *Server) handleAdminResetRecommendations(w http.ResponseWriter, r *http.Request) { userID := strings.TrimSpace(r.PathValue("userID")) if userID == "" { writeError(w, http.StatusBadRequest, "user is required") return } if err := s.store.ClearRecommendationOnboarding(r.Context(), userID); err != nil { writeError(w, http.StatusInternalServerError, "could not reset recommendation choices") return } if s.cache != nil { _ = s.cache.Delete(r.Context(), cache.RecommendationsKey(userID)) } w.WriteHeader(http.StatusNoContent) }