package wave import ( "context" "encoding/json" "fmt" "github.com/google/uuid" "pipu/api/internal/api/gen" "pipu/api/internal/platform/apperr" ) // Онбординг вкуса (docs/recommendations.md#7): новый пользователь отмечает исполнителей и жанры — рекомендатель // берёт их как начальный вкус, пока своей истории мало. Пропуск сохраняется пустым выбором. const ( onboardingArtists = 40 onboardingGenres = 24 ) // GetOnboarding реализует gen.StrictServerInterface. func (h *Handler) GetOnboarding(ctx context.Context, _ gen.GetOnboardingRequestObject) (gen.GetOnboardingResponseObject, error) { p, err := principal(ctx) if err != nil { return nil, err } out := gen.GetOnboarding200JSONResponse{} var raw []byte if err := h.pool.QueryRow(ctx, ` SELECT (SELECT onboarding || jsonb_build_object('age_group', rec_age_group(onboarding)) FROM user_profiles_rec WHERE user_id = $1)`, p.UserID).Scan(&raw); err != nil { return nil, fmt.Errorf("wave: onboarding state: %w", err) } if len(raw) > 0 { out.Choice = &gen.OnboardingChoice{} if err := json.Unmarshal(raw, out.Choice); err != nil { return nil, err } } rows, err := h.pool.Query(ctx, ` SELECT a.id, a.name, count(*) AS n, (SELECT al.id FROM albums al WHERE al.album_artist_id = a.id ORDER BY (SELECT count(*) FROM tracks t WHERE t.album_id = al.id) DESC, al.id LIMIT 1) FROM artists a JOIN track_artists ta ON ta.artist_id = a.id AND ta.role = 'main' GROUP BY a.id ORDER BY n DESC, a.name LIMIT $1`, onboardingArtists) if err != nil { return nil, fmt.Errorf("wave: onboarding artists: %w", err) } type artist struct { id, album *uuid.UUID name string n int } var artists []artist for rows.Next() { var a artist var id uuid.UUID if err := rows.Scan(&id, &a.name, &a.n, &a.album); err != nil { rows.Close() return nil, fmt.Errorf("wave: onboarding artists: %w", err) } a.id = &id artists = append(artists, a) } rows.Close() dto := h.dto for _, a := range artists { item := struct { CoverColors []string `json:"cover_colors"` CoverUrl *string `json:"cover_url"` Id uuid.UUID `json:"id"` Name string `json:"name"` TrackCount int `json:"track_count"` }{Id: *a.id, Name: a.name, TrackCount: a.n, CoverColors: []string{}} if a.album != nil { if al, err := h.catalog.Album(ctx, *a.album); err == nil { sum := dto.AlbumSummary(al) item.CoverUrl, item.CoverColors = sum.CoverUrl, sum.CoverColors } } out.Artists = append(out.Artists, item) } grows, err := h.pool.Query(ctx, ` SELECT g.name, count(DISTINCT tg.track_id) AS n FROM track_genres tg JOIN genres g ON g.id = tg.genre_id GROUP BY g.name ORDER BY n DESC, g.name LIMIT $1`, onboardingGenres) if err != nil { return nil, fmt.Errorf("wave: onboarding genres: %w", err) } for grows.Next() { var g struct { Name string `json:"name"` TrackCount int `json:"track_count"` } if err := grows.Scan(&g.Name, &g.TrackCount); err != nil { grows.Close() return nil, fmt.Errorf("wave: onboarding genres: %w", err) } out.Genres = append(out.Genres, g) } grows.Close() if out.Artists == nil { out.Artists = []struct { CoverColors []string `json:"cover_colors"` CoverUrl *string `json:"cover_url"` Id uuid.UUID `json:"id"` Name string `json:"name"` TrackCount int `json:"track_count"` }{} } if out.Genres == nil { out.Genres = []struct { Name string `json:"name"` TrackCount int `json:"track_count"` }{} } out.Required = out.Choice == nil return out, nil } // SaveOnboarding реализует gen.StrictServerInterface. func (h *Handler) SaveOnboarding(ctx context.Context, req gen.SaveOnboardingRequestObject) (gen.SaveOnboardingResponseObject, error) { p, err := principal(ctx) if err != nil { return nil, err } if req.Body == nil { return nil, apperr.Validation("body", "common.bad_request", "body required", nil) } if req.Body.Gender != nil && *req.Body.Gender != "male" && *req.Body.Gender != "female" && *req.Body.Gender != "other" { return nil, apperr.Validation("gender", "common.bad_request", "invalid gender", nil) } if req.Body.AgeGroup != nil { switch *req.Body.AgeGroup { case "under_13", "13_19", "20_29", "30_39", "40_49", "50_59", "60_plus": default: return nil, apperr.Validation("age_group", "common.bad_request", "invalid age group", nil) } } ids := req.Body.ArtistIds if ids == nil { ids = []uuid.UUID{} } genres := req.Body.Genres if genres == nil { genres = []string{} } raw, _ := json.Marshal(map[string]any{"artist_ids": ids, "genres": genres, "gender": req.Body.Gender, "age_group": req.Body.AgeGroup}) if _, err := h.pool.Exec(ctx, ` INSERT INTO user_profiles_rec (user_id, onboarding, updated_at) VALUES ($1, $2::jsonb || jsonb_build_object('age_group_year', EXTRACT(YEAR FROM CURRENT_DATE)::int), now()) ON CONFLICT (user_id) DO UPDATE SET onboarding = CASE WHEN rec_age_group(user_profiles_rec.onboarding) = EXCLUDED.onboarding->>'age_group' THEN EXCLUDED.onboarding || jsonb_build_object( 'age_group', user_profiles_rec.onboarding->'age_group', 'age_group_year', user_profiles_rec.onboarding->'age_group_year') ELSE EXCLUDED.onboarding END, updated_at = now()`, p.UserID, raw); err != nil { return nil, fmt.Errorf("wave: save onboarding: %w", err) } return gen.SaveOnboarding204Response{}, nil }