From 978f03b8f827e6761e21d8edfd21b6f432c1eb51 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:52:31 +0300 Subject: [PATCH] feat(admin): reset a user's recommendations from the users panel - 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 --- api/openapi.yaml | 16 + apps/web/src/App.test.tsx | 2 +- apps/web/src/pages/admin/UsersPage.tsx | 28 +- apps/web/src/shared/lib/policy.ts | 6 +- apps/web/src/test/fakeApi.ts | 2 + docs/admin-guide.md | 2 + docs/api.md | 1 + docs/auth-and-roles.md | 4 +- packages/api-client/src/generated/schema.ts | 45 ++ packages/i18n/locales/en/admin.json | 4 + packages/i18n/locales/ru/admin.json | 4 + services/api/internal/api/gen/api.gen.go | 597 ++++++++++-------- services/api/internal/auth/admin.go | 19 + services/api/internal/auth/admin_handler.go | 12 + services/api/internal/auth/admin_test.go | 18 + .../api/internal/auth/authtest/memstore.go | 16 +- services/api/internal/auth/pgstore.go | 16 + services/api/internal/auth/policy.go | 15 +- services/api/internal/auth/policy_test.go | 15 +- services/api/internal/auth/store.go | 3 + 20 files changed, 551 insertions(+), 274 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 49e2302..d4039f1 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -359,6 +359,22 @@ paths: $ref: "#/components/schemas/TemporaryPassword" default: $ref: "#/components/responses/Problem" + /api/v1/admin/users/{id}/reset-recommendations: + parameters: + - $ref: "#/components/parameters/UserId" + post: + operationId: resetUserRecommendations + tags: [admin] + summary: Сбросить рекомендации + description: | + Рекомендации начинаются с нуля: стираются профиль вкуса (онбординг, CF-вектор, бандит), + статистика прослушиваний и показы волны; онбординг покажется снова. + Лайки, дизлайки и история остаются. + responses: + "204": + description: Сброшено + default: + $ref: "#/components/responses/Problem" /api/v1/uploads/quota: get: operationId: getUploadQuota diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx index 8722b4a..d145d8f 100644 --- a/apps/web/src/App.test.tsx +++ b/apps/web/src/App.test.tsx @@ -164,7 +164,7 @@ describe('users admin', () => { await user.click(screen.getByRole('button', { name: 'Действия с anna' })); const items = screen.getAllByRole('menuitem').map((i) => i.textContent); - expect(items).toEqual(['Сбросить пароль', 'Заблокировать']); + expect(items).toEqual(['Сбросить пароль', 'Сбросить рекомендации', 'Заблокировать']); await user.keyboard('{Escape}'); await user.click(screen.getByRole('button', { name: 'Создать пользователя' })); diff --git a/apps/web/src/pages/admin/UsersPage.tsx b/apps/web/src/pages/admin/UsersPage.tsx index a1ed773..4092c75 100644 --- a/apps/web/src/pages/admin/UsersPage.tsx +++ b/apps/web/src/pages/admin/UsersPage.tsx @@ -8,7 +8,7 @@ import { useSession, useUser } from '../../app/session'; import { useErrorText, useFieldError } from '../../shared/lib/errors'; import { formatRelative } from '../../shared/lib/format'; import { assignableRoles, canManageUser, type Role } from '../../shared/lib/policy'; -import { addIcon, checkIcon, copyIcon, lockResetIcon, moreIcon, searchIcon } from '../../shared/ui/icons'; +import { addIcon, checkIcon, copyIcon, lockResetIcon, moreIcon, refreshIcon, searchIcon } from '../../shared/ui/icons'; import { Avatar, Button, cx, Dialog, Field, FormError, Icon, Menu, type MenuItem } from '../../shared/ui/ui'; import s from '../page.module.css'; @@ -16,7 +16,9 @@ type AdminUser = components['schemas']['AdminUser']; const ROLES: Role[] = ['admin', 'moderator', 'user']; -type Confirm = { kind: 'reset' | 'block' | 'delete'; user: AdminUser }; +const TITLES = { reset: 'reset_password', recs: 'reset_recommendations', block: 'block', delete: 'delete' } as const; + +type Confirm = { kind: 'reset' | 'recs' | 'block' | 'delete'; user: AdminUser }; function useDebounced(value: T, ms: number): T { const [v, setV] = useState(value); @@ -47,6 +49,7 @@ export function UsersPage() { const [confirm, setConfirm] = useState(null); const [secret, setSecret] = useState<{ username: string; password: string } | null>(null); const [actionError, setActionError] = useState(null); + const [recsDone, setRecsDone] = useState(null); const list = useInfiniteQuery({ queryKey: ['admin-users', query, role], @@ -79,6 +82,14 @@ export function UsersPage() { }, onError: setActionError, }); + const resetRecs = useMutation({ + mutationFn: async (u: AdminUser) => + unwrap( + await session.client.POST('/api/v1/admin/users/{id}/reset-recommendations', { params: { path: { id: u.id } } }), + ), + onSuccess: (_, u) => setRecsDone(u.username), + onError: setActionError, + }); const remove = useMutation({ mutationFn: async (u: AdminUser) => unwrap(await session.client.DELETE('/api/v1/admin/users/{id}', { params: { path: { id: u.id } } })), @@ -91,7 +102,9 @@ export function UsersPage() { const { kind, user } = confirm; setConfirm(null); setActionError(null); + setRecsDone(null); if (kind === 'reset') reset.mutate(user); + if (kind === 'recs') resetRecs.mutate(user); if (kind === 'block') patch.mutate({ id: user.id, body: { is_blocked: true } }); if (kind === 'delete') remove.mutate(user); }; @@ -101,6 +114,12 @@ export function UsersPage() { const can = (a: Parameters[2]) => canManageUser(me, u, a); if (can('reset_password')) items.push({ label: t('users.reset_password'), icon: lockResetIcon, onSelect: () => setConfirm({ kind: 'reset', user: u }) }); + if (can('reset_recommendations')) + items.push({ + label: t('users.reset_recommendations'), + icon: refreshIcon, + onSelect: () => setConfirm({ kind: 'recs', user: u }), + }); if (can('block')) items.push( u.is_blocked @@ -154,6 +173,7 @@ export function UsersPage() { {actionError || list.error ? errorText(actionError ?? list.error) : null} + {recsDone &&

{t('users.recs_done', { username: recsDone })}

}
@@ -226,14 +246,14 @@ export function UsersPage() { {confirm && ( setConfirm(null)} footer={ <> - diff --git a/apps/web/src/shared/lib/policy.ts b/apps/web/src/shared/lib/policy.ts index 42083d4..a64dae5 100644 --- a/apps/web/src/shared/lib/policy.ts +++ b/apps/web/src/shared/lib/policy.ts @@ -1,7 +1,7 @@ import type { components } from '@pipu/api-client'; export type Role = components['schemas']['Role']; -export type UserAction = 'reset_password' | 'block' | 'change_role' | 'delete'; +export type UserAction = 'reset_password' | 'reset_recommendations' | 'block' | 'change_role' | 'delete'; /** * Копия матрицы прав (docs/auth-and-roles.md, services/api/internal/auth/policy.go) — только для UI: @@ -9,9 +9,9 @@ export type UserAction = 'reset_password' | 'block' | 'change_role' | 'delete'; */ export function canManageUser(actor: { id: string; role: Role }, target: { id: string; role: Role }, action: UserAction) { const self = actor.id === target.id; - if (self && action !== 'reset_password') return false; + if (self && (action === 'block' || action === 'change_role' || action === 'delete')) return false; if (actor.role === 'admin') return true; - if (actor.role === 'moderator') return (action === 'reset_password' || action === 'block') && target.role === 'user'; + if (actor.role === 'moderator') return action !== 'change_role' && action !== 'delete' && target.role === 'user'; return false; } diff --git a/apps/web/src/test/fakeApi.ts b/apps/web/src/test/fakeApi.ts index eceb44c..708b857 100644 --- a/apps/web/src/test/fakeApi.ts +++ b/apps/web/src/test/fakeApi.ts @@ -223,6 +223,8 @@ export function fakeApi(initial: FakeUser[]) { case `GET /api/v1/artists/${kino.id}/albums`: return json({ items: [albumSummary] }); } + if (req.method === 'POST' && /^\/api\/v1\/admin\/users\/[^/]+\/reset-recommendations$/.test(path)) + return new Response(null, { status: 204 }); const reactionPath = /^\/api\/v1\/me\/reactions\/tracks\/([^/]+)$/.exec(path); if (reactionPath) { if (req.method === 'PUT') reactions.set(reactionPath[1]!, body?.reaction as 'like' | 'dislike'); diff --git a/docs/admin-guide.md b/docs/admin-guide.md index 1fca3c5..fc91a5b 100644 --- a/docs/admin-guide.md +++ b/docs/admin-guide.md @@ -27,6 +27,8 @@ - **Новый пользователь:** «Создать» → логин, имя, роль. Сервер выдаёт **временный пароль** — он показывается один раз, передайте его человеку. При первом входе пароль придётся сменить. - **Забыл пароль:** «Сбросить пароль» — новый временный пароль, все сессии пользователя завершаются. +- **Волна «застряла» во вкусе:** «Сбросить рекомендации» — профиль вкуса, статистика прослушиваний и показы волны + стираются, онбординг покажется снова. Лайки, дизлайки и история остаются (и по-прежнему влияют на подбор). - **Заблокировать** — вход запрещён, история и лайки сохраняются; **удалить** — безвозвратно вместе с историей. - Последнего администратора нельзя понизить, заблокировать или удалить. - **Аудит** («Администрирование → Аудит»): кто, когда и что сделал — создание пользователей, смена ролей, сбросы diff --git a/docs/api.md b/docs/api.md index 2a7bf91..dc3aeb8 100644 --- a/docs/api.md +++ b/docs/api.md @@ -114,6 +114,7 @@ | POST | `/admin/users` `{username, display_name, role}` → `{user, temporary_password}` (пароль генерирует сервер, `must_change_password = true`) | moderator: только `role=user`; admin: любые | | PATCH | `/admin/users/{id}` `{display_name?, role?, is_blocked?}` | role — только admin | | POST | `/admin/users/{id}/reset-password` → `{temporary_password}` | moderator — только для user | +| POST | `/admin/users/{id}/reset-recommendations` → 204: рекомендации с нуля (профиль, статистика, показы волны) | moderator — только для user | | DELETE | `/admin/users/{id}` | admin | | GET/POST | `/admin/sources` | admin | | GET/PATCH/DELETE | `/admin/sources/{id}` | admin | diff --git a/docs/auth-and-roles.md b/docs/auth-and-roles.md index 6f5cc4b..f1c11f2 100644 --- a/docs/auth-and-roles.md +++ b/docs/auth-and-roles.md @@ -17,9 +17,9 @@ | Открыть админку | — | ✅ | ✅ | | Видеть список пользователей | — | ✅ | ✅ | | Создать пользователя с ролью `user` | — | ✅ | ✅ | -| Сбросить пароль / заблокировать `user` | — | ✅ | ✅ | +| Сбросить пароль / рекомендации, заблокировать `user` | — | ✅ | ✅ | | Создать `moderator` / `admin`, менять роли | — | — | ✅ | -| Сбросить пароль / заблокировать `moderator` | — | — | ✅ | +| Сбросить пароль / рекомендации, заблокировать `moderator` | — | — | ✅ | | Удалить пользователя | — | — | ✅ | | Хранилища, сканирование, переиндексация | — | — | ✅ | | Статус библиотеки (только просмотр) | — | ✅ | ✅ | diff --git a/packages/api-client/src/generated/schema.ts b/packages/api-client/src/generated/schema.ts index 7265dba..e231aa1 100644 --- a/packages/api-client/src/generated/schema.ts +++ b/packages/api-client/src/generated/schema.ts @@ -305,6 +305,30 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/admin/users/{id}/reset-recommendations": { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["UserId"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Сбросить рекомендации + * @description Рекомендации начинаются с нуля: стираются профиль вкуса (онбординг, CF-вектор, бандит), + * статистика прослушиваний и показы волны; онбординг покажется снова. + * Лайки, дизлайки и история остаются. + */ + post: operations["resetUserRecommendations"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/uploads/quota": { parameters: { query?: never; @@ -2559,6 +2583,27 @@ export interface operations { default: components["responses"]["Problem"]; }; }; + resetUserRecommendations: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["UserId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Сброшено */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + default: components["responses"]["Problem"]; + }; + }; getUploadQuota: { parameters: { query?: never; diff --git a/packages/i18n/locales/en/admin.json b/packages/i18n/locales/en/admin.json index 53008e0..70b1db5 100644 --- a/packages/i18n/locales/en/admin.json +++ b/packages/i18n/locales/en/admin.json @@ -24,6 +24,9 @@ "delete_confirm": "Delete {{username}}? Their history and likes will be lost.", "block_confirm": "Block {{username}}? All their sessions will end.", "reset_confirm": "Reset {{username}}'s password? All their sessions will end.", + "reset_recommendations": "Reset recommendations", + "recs_confirm": "Reset {{username}}'s recommendations? The wave will start learning their taste from scratch and ask for favourite artists again. Likes and history stay.", + "recs_done": "{{username}}'s recommendations were reset", "confirm": "Confirm", "you": "you" }, @@ -60,6 +63,7 @@ "user.block": "User blocked", "user.unblock": "User unblocked", "user.password_reset": "Password reset", + "user.recommendations_reset": "Recommendations reset", "user.delete": "User deleted" } }, diff --git a/packages/i18n/locales/ru/admin.json b/packages/i18n/locales/ru/admin.json index 66dd917..2db6218 100644 --- a/packages/i18n/locales/ru/admin.json +++ b/packages/i18n/locales/ru/admin.json @@ -24,6 +24,9 @@ "delete_confirm": "Удалить {{username}}? Его история и лайки пропадут.", "block_confirm": "Заблокировать {{username}}? Все его сессии завершатся.", "reset_confirm": "Сбросить пароль {{username}}? Все его сессии завершатся.", + "reset_recommendations": "Сбросить рекомендации", + "recs_confirm": "Сбросить рекомендации {{username}}? Волна начнёт подбирать музыку с нуля и снова спросит любимых исполнителей. Лайки и история останутся.", + "recs_done": "Рекомендации {{username}} сброшены", "confirm": "Подтвердить", "you": "вы" }, @@ -60,6 +63,7 @@ "user.block": "Пользователь заблокирован", "user.unblock": "Пользователь разблокирован", "user.password_reset": "Сброшен пароль", + "user.recommendations_reset": "Сброшены рекомендации", "user.delete": "Пользователь удалён" } }, diff --git a/services/api/internal/api/gen/api.gen.go b/services/api/internal/api/gen/api.gen.go index 888444d..b8380b9 100644 --- a/services/api/internal/api/gen/api.gen.go +++ b/services/api/internal/api/gen/api.gen.go @@ -1573,6 +1573,9 @@ type ServerInterface interface { // Сбросить пароль // (POST /api/v1/admin/users/{id}/reset-password) ResetUserPassword(w http.ResponseWriter, r *http.Request, id UserId) + // Сбросить рекомендации + // (POST /api/v1/admin/users/{id}/reset-recommendations) + ResetUserRecommendations(w http.ResponseWriter, r *http.Request, id UserId) // Альбом с треками // (GET /api/v1/albums/{id}) GetAlbum(w http.ResponseWriter, r *http.Request, id EntityId) @@ -1891,6 +1894,12 @@ func (_ Unimplemented) ResetUserPassword(w http.ResponseWriter, r *http.Request, w.WriteHeader(http.StatusNotImplemented) } +// Сбросить рекомендации +// (POST /api/v1/admin/users/{id}/reset-recommendations) +func (_ Unimplemented) ResetUserRecommendations(w http.ResponseWriter, r *http.Request, id UserId) { + w.WriteHeader(http.StatusNotImplemented) +} + // Альбом с треками // (GET /api/v1/albums/{id}) func (_ Unimplemented) GetAlbum(w http.ResponseWriter, r *http.Request, id EntityId) { @@ -3066,6 +3075,37 @@ func (siw *ServerInterfaceWrapper) ResetUserPassword(w http.ResponseWriter, r *h handler.ServeHTTP(w, r) } +// ResetUserRecommendations operation middleware +func (siw *ServerInterfaceWrapper) ResetUserRecommendations(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "id" ------------- + var id UserId + + err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ResetUserRecommendations(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // GetAlbum operation middleware func (siw *ServerInterfaceWrapper) GetAlbum(w http.ResponseWriter, r *http.Request) { @@ -4581,6 +4621,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/api/v1/admin/users/{id}/reset-password", wrapper.ResetUserPassword) }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/api/v1/admin/users/{id}/reset-recommendations", wrapper.ResetUserRecommendations) + }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/api/v1/albums/{id}", wrapper.GetAlbum) }) @@ -5567,6 +5610,34 @@ func (response ResetUserPassworddefaultApplicationProblemPlusJSONResponse) Visit return json.NewEncoder(w).Encode(response.Body) } +type ResetUserRecommendationsRequestObject struct { + Id UserId `json:"id"` +} + +type ResetUserRecommendationsResponseObject interface { + VisitResetUserRecommendationsResponse(w http.ResponseWriter) error +} + +type ResetUserRecommendations204Response struct { +} + +func (response ResetUserRecommendations204Response) VisitResetUserRecommendationsResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type ResetUserRecommendationsdefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response ResetUserRecommendationsdefaultApplicationProblemPlusJSONResponse) VisitResetUserRecommendationsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + type GetAlbumRequestObject struct { Id EntityId `json:"id"` } @@ -6991,6 +7062,9 @@ type StrictServerInterface interface { // Сбросить пароль // (POST /api/v1/admin/users/{id}/reset-password) ResetUserPassword(ctx context.Context, request ResetUserPasswordRequestObject) (ResetUserPasswordResponseObject, error) + // Сбросить рекомендации + // (POST /api/v1/admin/users/{id}/reset-recommendations) + ResetUserRecommendations(ctx context.Context, request ResetUserRecommendationsRequestObject) (ResetUserRecommendationsResponseObject, error) // Альбом с треками // (GET /api/v1/albums/{id}) GetAlbum(ctx context.Context, request GetAlbumRequestObject) (GetAlbumResponseObject, error) @@ -7973,6 +8047,32 @@ func (sh *strictHandler) ResetUserPassword(w http.ResponseWriter, r *http.Reques } } +// ResetUserRecommendations operation middleware +func (sh *strictHandler) ResetUserRecommendations(w http.ResponseWriter, r *http.Request, id UserId) { + var request ResetUserRecommendationsRequestObject + + request.Id = id + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.ResetUserRecommendations(ctx, request.(ResetUserRecommendationsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ResetUserRecommendations") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ResetUserRecommendationsResponseObject); ok { + if err := validResponse.VisitResetUserRecommendationsResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // GetAlbum operation middleware func (sh *strictHandler) GetAlbum(w http.ResponseWriter, r *http.Request, id EntityId) { var request GetAlbumRequestObject @@ -9232,253 +9332,256 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", + "/4K30PH7PwEA", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/services/api/internal/auth/admin.go b/services/api/internal/auth/admin.go index d0e54fa..248fda2 100644 --- a/services/api/internal/auth/admin.go +++ b/services/api/internal/auth/admin.go @@ -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 { diff --git a/services/api/internal/auth/admin_handler.go b/services/api/internal/auth/admin_handler.go index f552c83..407f153 100644 --- a/services/api/internal/auth/admin_handler.go +++ b/services/api/internal/auth/admin_handler.go @@ -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) diff --git a/services/api/internal/auth/admin_test.go b/services/api/internal/auth/admin_test.go index afe4b63..123c2eb 100644 --- a/services/api/internal/auth/admin_test.go +++ b/services/api/internal/auth/admin_test.go @@ -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) diff --git a/services/api/internal/auth/authtest/memstore.go b/services/api/internal/auth/authtest/memstore.go index 6bb551b..f01d0d1 100644 --- a/services/api/internal/auth/authtest/memstore.go +++ b/services/api/internal/auth/authtest/memstore.go @@ -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()() diff --git a/services/api/internal/auth/pgstore.go b/services/api/internal/auth/pgstore.go index ce63ed5..7f79565 100644 --- a/services/api/internal/auth/pgstore.go +++ b/services/api/internal/auth/pgstore.go @@ -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, ` diff --git a/services/api/internal/auth/policy.go b/services/api/internal/auth/policy.go index 314e378..3b0a545 100644 --- a/services/api/internal/auth/policy.go +++ b/services/api/internal/auth/policy.go @@ -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 diff --git a/services/api/internal/auth/policy_test.go b/services/api/internal/auth/policy_test.go index da5643b..17c2be3 100644 --- a/services/api/internal/auth/policy_test.go +++ b/services/api/internal/auth/policy_test.go @@ -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 { diff --git a/services/api/internal/auth/store.go b/services/api/internal/auth/store.go index 6f271d1..2671127 100644 --- a/services/api/internal/auth/store.go +++ b/services/api/internal/auth/store.go @@ -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 — с начала).