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

- 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:
mrleo1nidandClaude Opus 5.5 committed 2026-10-21 08:52:31 +03:00
1 parent b27b915b63
commit 978f03b8f8
20 files changed
+551 -274

No files matched your search

+19
View File
@@ -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)
+18
View File
@@ -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()()
+16
View File
@@ -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, `
+8 -7
View File
@@ -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
+8 -7
View File
@@ -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 {
+3
View File
@@ -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 — с начала).