feat(admin): reset a user's recommendations from the users panel
ci / api / api (push) Successful in 40s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 55s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m10s
ci / db / db (push) Successful in 2m35s
ci / ts / ts (packages + web) (push) Successful in 1m23s
ci / api / api (push) Successful in 40s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 55s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m10s
ci / db / db (push) Successful in 2m35s
ci / ts / ts (packages + web) (push) Successful in 1m23s
- POST /admin/users/{id}/reset-recommendations clears the taste profile
(onboarding, CF vector, bandit), play stats and wave impressions; likes,
dislikes and history stay, wave sessions stay so a playing wave keeps going
- moderators may reset plain users, admins anyone including themselves;
the reset is written to the audit log
- users panel gets the menu action with a confirmation; docs updated
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b27b915b63
commit
978f03b8f8
20 files changed
+551
-274
No files matched your search
@@ -290,6 +290,25 @@ func (s *Service) ResetPassword(ctx context.Context, p Principal, id uuid.UUID)
|
||||
return password, nil
|
||||
}
|
||||
|
||||
// ResetRecommendations начинает рекомендации пользователя с нуля; онбординг покажется снова.
|
||||
func (s *Service) ResetRecommendations(ctx context.Context, p Principal, id uuid.UUID) error {
|
||||
return s.store.WithTx(ctx, func(tx Store) error {
|
||||
target, err := s.target(ctx, tx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !CanManageUser(actorOf(p), actorOfUser(target), UserResetRecommendations) {
|
||||
return forbidden()
|
||||
}
|
||||
if err := tx.ResetRecommendations(ctx, target.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.InsertAudit(ctx, s.ids.New(), AuditEntry{
|
||||
ActorID: &p.UserID, Action: "user.recommendations_reset", TargetType: "user", TargetID: &target.ID,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteUser удаляет пользователя (только админ, не себя и не последнего админа).
|
||||
func (s *Service) DeleteUser(ctx context.Context, p Principal, id uuid.UUID) error {
|
||||
return s.store.WithTx(ctx, func(tx Store) error {
|
||||
|
||||
@@ -97,6 +97,18 @@ func (h *Handler) ResetUserPassword(ctx context.Context, req gen.ResetUserPasswo
|
||||
return gen.ResetUserPassword200JSONResponse{TemporaryPassword: password}, nil
|
||||
}
|
||||
|
||||
// ResetUserRecommendations реализует gen.StrictServerInterface.
|
||||
func (h *Handler) ResetUserRecommendations(ctx context.Context, req gen.ResetUserRecommendationsRequestObject) (gen.ResetUserRecommendationsResponseObject, error) {
|
||||
p, err := principal(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := h.svc.ResetRecommendations(ctx, p, req.Id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.ResetUserRecommendations204Response{}, nil
|
||||
}
|
||||
|
||||
// ListAudit реализует gen.StrictServerInterface.
|
||||
func (h *Handler) ListAudit(ctx context.Context, req gen.ListAuditRequestObject) (gen.ListAuditResponseObject, error) {
|
||||
p, err := principal(ctx)
|
||||
|
||||
@@ -284,6 +284,24 @@ func TestResetPassword(t *testing.T) {
|
||||
assert.Equal(t, "user.password_reset", e.store.Audit[len(e.store.Audit)-1].Action)
|
||||
}
|
||||
|
||||
func TestResetRecommendations(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.addUser("mod", "moderator-pass-1", auth.RoleModerator)
|
||||
peer := e.addUser("other", "moderator-pass-2", auth.RoleModerator)
|
||||
target := e.addUser("listener", "listener-pass", auth.RoleUser)
|
||||
mod := e.signIn("mod", "moderator-pass-1")
|
||||
|
||||
r := e.do(http.MethodPost, "/api/v1/admin/users/"+target.ID.String()+"/reset-recommendations", nil, bearer(mod))
|
||||
require.Equal(t, http.StatusNoContent, r.Code, r.Body.String())
|
||||
assert.Equal(t, []uuid.UUID{target.ID}, e.store.RecResets)
|
||||
assert.Equal(t, "user.recommendations_reset", e.store.Audit[len(e.store.Audit)-1].Action)
|
||||
|
||||
// Модератор не трогает других модераторов.
|
||||
assert.Equal(t, http.StatusForbidden,
|
||||
e.do(http.MethodPost, "/api/v1/admin/users/"+peer.ID.String()+"/reset-recommendations", nil, bearer(mod)).Code)
|
||||
assert.Len(t, e.store.RecResets, 1)
|
||||
}
|
||||
|
||||
func TestDeleteUser(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.addUser("root", "root-password-1", auth.RoleAdmin)
|
||||
|
||||
@@ -25,6 +25,8 @@ type MemStore struct {
|
||||
Audit []auth.AuditEntry
|
||||
AuditIDs []uuid.UUID
|
||||
Settings map[string]any
|
||||
// RecResets — для кого сбрасывали рекомендации (таблиц рекомендатора в памяти нет).
|
||||
RecResets []uuid.UUID
|
||||
}
|
||||
|
||||
// NewMemStore создаёт пустое хранилище.
|
||||
@@ -50,15 +52,16 @@ func (s *MemStore) WithTx(_ context.Context, fn func(auth.Store) error) error {
|
||||
unlock := s.lock()
|
||||
defer unlock()
|
||||
users, tokens, settings := maps.Clone(s.Users), maps.Clone(s.Tokens), maps.Clone(s.Settings)
|
||||
audit, auditIDs := slices.Clone(s.Audit), slices.Clone(s.AuditIDs)
|
||||
audit, auditIDs, resets := slices.Clone(s.Audit), slices.Clone(s.AuditIDs), slices.Clone(s.RecResets)
|
||||
tx := &MemStore{
|
||||
mu: s.mu, inTx: true, Users: s.Users, Tokens: s.Tokens, Audit: s.Audit, AuditIDs: s.AuditIDs, Settings: s.Settings,
|
||||
RecResets: s.RecResets,
|
||||
}
|
||||
if err := fn(tx); err != nil {
|
||||
s.Users, s.Tokens, s.Audit, s.AuditIDs, s.Settings = users, tokens, audit, auditIDs, settings
|
||||
s.Users, s.Tokens, s.Audit, s.AuditIDs, s.Settings, s.RecResets = users, tokens, audit, auditIDs, settings, resets
|
||||
return err
|
||||
}
|
||||
s.Users, s.Tokens, s.Audit, s.AuditIDs, s.Settings = tx.Users, tx.Tokens, tx.Audit, tx.AuditIDs, tx.Settings
|
||||
s.Users, s.Tokens, s.Audit, s.AuditIDs, s.Settings, s.RecResets = tx.Users, tx.Tokens, tx.Audit, tx.AuditIDs, tx.Settings, tx.RecResets
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -319,6 +322,13 @@ func (s *MemStore) ResetPassword(_ context.Context, id uuid.UUID, hash string) e
|
||||
})
|
||||
}
|
||||
|
||||
// ResetRecommendations реализует auth.Store.
|
||||
func (s *MemStore) ResetRecommendations(_ context.Context, id uuid.UUID) error {
|
||||
defer s.lock()()
|
||||
s.RecResets = append(s.RecResets, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteUser реализует auth.Store: токены удаляются, ссылки в журнале обнуляются, как в PostgreSQL.
|
||||
func (s *MemStore) DeleteUser(_ context.Context, id uuid.UUID) error {
|
||||
defer s.lock()()
|
||||
|
||||
@@ -370,6 +370,22 @@ func (s *PgStore) DeleteUser(ctx context.Context, id uuid.UUID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetRecommendations реализует Store. Сессии волны остаются — играющая волна не ломается;
|
||||
// без показов бандит при ночном пересчёте начинает заново.
|
||||
func (s *PgStore) ResetRecommendations(ctx context.Context, id uuid.UUID) error {
|
||||
for _, q := range []string{
|
||||
`DELETE FROM user_profiles_rec WHERE user_id = $1`,
|
||||
`DELETE FROM user_track_stats WHERE user_id = $1`,
|
||||
`DELETE FROM wave_impressions WHERE wave_session_id IN (SELECT id FROM wave_sessions WHERE user_id = $1)`,
|
||||
`DELETE FROM rec_impression_features WHERE wave_session_id IN (SELECT id FROM wave_sessions WHERE user_id = $1)`,
|
||||
} {
|
||||
if _, err := s.q.Exec(ctx, q, id); err != nil {
|
||||
return fmt.Errorf("auth: reset recommendations: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListAudit реализует Store.
|
||||
func (s *PgStore) ListAudit(ctx context.Context, before uuid.UUID, limit int) ([]AuditRecord, error) {
|
||||
rows, err := s.q.Query(ctx, `
|
||||
|
||||
@@ -43,12 +43,13 @@ type UserAction int
|
||||
|
||||
// Действия над пользователями.
|
||||
const (
|
||||
UserCreate UserAction = iota // создать пользователя с ролью target
|
||||
UserEdit // изменить отображаемое имя
|
||||
UserResetPassword // сбросить пароль
|
||||
UserBlock // заблокировать / разблокировать
|
||||
UserChangeRole // сменить роль (target — текущая роль)
|
||||
UserDelete // удалить
|
||||
UserCreate UserAction = iota // создать пользователя с ролью target
|
||||
UserEdit // изменить отображаемое имя
|
||||
UserResetPassword // сбросить пароль
|
||||
UserBlock // заблокировать / разблокировать
|
||||
UserChangeRole // сменить роль (target — текущая роль)
|
||||
UserDelete // удалить
|
||||
UserResetRecommendations // сбросить профиль рекомендаций
|
||||
)
|
||||
|
||||
// Actor — кто действует (или над кем). ID нужен, чтобы запретить действия над собой.
|
||||
@@ -71,7 +72,7 @@ func CanManageUser(actor, target Actor, action UserAction) bool {
|
||||
return true
|
||||
case RoleModerator:
|
||||
switch action {
|
||||
case UserCreate, UserEdit, UserResetPassword, UserBlock:
|
||||
case UserCreate, UserEdit, UserResetPassword, UserBlock, UserResetRecommendations:
|
||||
return target.Role == RoleUser
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -37,16 +37,17 @@ func TestCan(t *testing.T) {
|
||||
func TestCanManageUser(t *testing.T) {
|
||||
actions := map[UserAction]string{
|
||||
UserCreate: "create", UserEdit: "edit", UserResetPassword: "reset", UserBlock: "block",
|
||||
UserChangeRole: "role", UserDelete: "delete",
|
||||
UserChangeRole: "role", UserDelete: "delete", UserResetRecommendations: "recs",
|
||||
}
|
||||
// want[actor][target] для каждого действия; порядок ролей — user, moderator, admin.
|
||||
cases := map[UserAction][3][3]bool{
|
||||
UserCreate: {{false, false, false}, {true, false, false}, {true, true, true}},
|
||||
UserEdit: {{false, false, false}, {true, false, false}, {true, true, true}},
|
||||
UserResetPassword: {{false, false, false}, {true, false, false}, {true, true, true}},
|
||||
UserBlock: {{false, false, false}, {true, false, false}, {true, true, true}},
|
||||
UserChangeRole: {{false, false, false}, {false, false, false}, {true, true, true}},
|
||||
UserDelete: {{false, false, false}, {false, false, false}, {true, true, true}},
|
||||
UserCreate: {{false, false, false}, {true, false, false}, {true, true, true}},
|
||||
UserEdit: {{false, false, false}, {true, false, false}, {true, true, true}},
|
||||
UserResetPassword: {{false, false, false}, {true, false, false}, {true, true, true}},
|
||||
UserBlock: {{false, false, false}, {true, false, false}, {true, true, true}},
|
||||
UserChangeRole: {{false, false, false}, {false, false, false}, {true, true, true}},
|
||||
UserDelete: {{false, false, false}, {false, false, false}, {true, true, true}},
|
||||
UserResetRecommendations: {{false, false, false}, {true, false, false}, {true, true, true}},
|
||||
}
|
||||
roles := [3]Role{RoleUser, RoleModerator, RoleAdmin}
|
||||
for action, want := range cases {
|
||||
|
||||
@@ -75,6 +75,9 @@ type Store interface {
|
||||
// ResetPassword ставит временный пароль: must_change_password, сброс блокировки входа, token_version + 1.
|
||||
ResetPassword(ctx context.Context, id uuid.UUID, hash string) error
|
||||
DeleteUser(ctx context.Context, id uuid.UUID) error
|
||||
// ResetRecommendations стирает то, из чего рекомендатор строит вкус: профиль (онбординг, CF, бандит),
|
||||
// агрегаты прослушиваний и показы волны. Лайки, дизлайки и история остаются.
|
||||
ResetRecommendations(ctx context.Context, id uuid.UUID) error
|
||||
|
||||
InsertAudit(ctx context.Context, id uuid.UUID, e AuditEntry) error
|
||||
// ListAudit — записи журнала новее-первыми, с id меньше before (uuid.Nil — с начала).
|
||||
|
||||
Reference in new issue
Block a user