Add Memby account management
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user