diff --git a/admin/admin.go b/admin/admin.go index 2eccf2c..db1e2f1 100644 --- a/admin/admin.go +++ b/admin/admin.go @@ -15,13 +15,15 @@ import ( ) var profilesDB = sqldb.Named("profiles") +var broadcastersDB = sqldb.Named("broadcasters") type Profile struct { UserID uuid.UUID `json:"user_id"` Email string `json:"email"` DisplayName string `json:"display_name"` AvatarURL string `json:"avatar_url"` - IsArtist bool `json:"is_artist"` + MemberType string `json:"member_type"` + Broadcaster *uuid.UUID `json:"broadcaster"` Role string `json:"role"` Status profilessvc.Status `json:"status"` CreatedAt time.Time `json:"created_at"` @@ -49,7 +51,7 @@ type ListProfilesResponse struct { //encore:api auth method=GET path=/admin/profiles func ListProfiles(ctx context.Context) (*ListProfilesResponse, error) { rows, err := profilesDB.Query(ctx, ` - SELECT user_id, email, display_name, avatar_url, role, status, is_artist, created_at, updated_at + SELECT user_id, email, display_name, avatar_url, role, status, member_type, broadcaster, created_at, updated_at FROM user_profiles ORDER BY user_id `) @@ -61,7 +63,7 @@ func ListProfiles(ctx context.Context) (*ListProfilesResponse, error) { profiles := []*Profile{} for rows.Next() { var p Profile - if err := rows.Scan(&p.UserID, &p.Email, &p.DisplayName, &p.AvatarURL, &p.Role, &p.Status, &p.IsArtist, &p.CreatedAt, &p.UpdatedAt); err != nil { + if err := rows.Scan(&p.UserID, &p.Email, &p.DisplayName, &p.AvatarURL, &p.Role, &p.Status, &p.MemberType, &p.Broadcaster, &p.CreatedAt, &p.UpdatedAt); err != nil { return nil, errs.WrapCode(err, errs.Internal, "failed to scan profile") } profiles = append(profiles, &p) @@ -78,8 +80,8 @@ func ListProfiles(ctx context.Context) (*ListProfilesResponse, error) { func GetProfile(ctx context.Context, userID uuid.UUID) (*Profile, error) { p := Profile{UserID: userID} err := profilesDB.QueryRow(ctx, ` - SELECT email, display_name, avatar_url, role, status, is_artist, created_at, updated_at FROM user_profiles WHERE user_id = $1 - `, userID).Scan(&p.Email, &p.DisplayName, &p.AvatarURL, &p.Role, &p.Status, &p.IsArtist, &p.CreatedAt, &p.UpdatedAt) + SELECT email, display_name, avatar_url, role, status, member_type, broadcaster, created_at, updated_at FROM user_profiles WHERE user_id = $1 + `, userID).Scan(&p.Email, &p.DisplayName, &p.AvatarURL, &p.Role, &p.Status, &p.MemberType, &p.Broadcaster, &p.CreatedAt, &p.UpdatedAt) if errors.Is(err, sqldb.ErrNoRows) { return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"} } @@ -102,8 +104,8 @@ func InsertProfile(ctx context.Context, p *RegisterParams) (*Profile, error) { err = profilesDB.QueryRow(ctx, ` INSERT INTO user_profiles (user_id, email, display_name, avatar_url) VALUES ($1, $2, $3, $4) - RETURNING role, status, is_artist, created_at, updated_at - `, userID, p.Email, p.DisplayName, p.AvatarURL).Scan(&profile.Role, &profile.Status, &profile.IsArtist, &profile.CreatedAt, &profile.UpdatedAt) + RETURNING role, status, member_type, broadcaster, created_at, updated_at + `, userID, p.Email, p.DisplayName, p.AvatarURL).Scan(&profile.Role, &profile.Status, &profile.MemberType, &profile.Broadcaster, &profile.CreatedAt, &profile.UpdatedAt) if isUniqueViolation(err) { return nil, &errs.Error{Code: errs.AlreadyExists, Message: "email already registered"} } @@ -125,8 +127,8 @@ func UpdateProfile(ctx context.Context, userID uuid.UUID, p *ProfileParams) (*Pr UPDATE user_profiles SET display_name = $2, avatar_url = $3, updated_at = NOW() WHERE user_id = $1 - RETURNING email, role, status, is_artist, created_at, updated_at - `, userID, p.DisplayName, p.AvatarURL).Scan(&profile.Email, &profile.Role, &profile.Status, &profile.IsArtist, &profile.CreatedAt, &profile.UpdatedAt) + RETURNING email, role, status, member_type, broadcaster, created_at, updated_at + `, userID, p.DisplayName, p.AvatarURL).Scan(&profile.Email, &profile.Role, &profile.Status, &profile.MemberType, &profile.Broadcaster, &profile.CreatedAt, &profile.UpdatedAt) if errors.Is(err, sqldb.ErrNoRows) { return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"} } @@ -156,8 +158,8 @@ func UpdateProfileStatus(ctx context.Context, userID uuid.UUID, p *UpdateProfile UPDATE user_profiles SET status = $2, updated_at = NOW() WHERE user_id = $1 - RETURNING email, display_name, avatar_url, role, is_artist, created_at, updated_at - `, userID, p.Status).Scan(&profile.Email, &profile.DisplayName, &profile.AvatarURL, &profile.Role, &profile.IsArtist, &profile.CreatedAt, &profile.UpdatedAt) + RETURNING email, display_name, avatar_url, role, member_type, broadcaster, created_at, updated_at + `, userID, p.Status).Scan(&profile.Email, &profile.DisplayName, &profile.AvatarURL, &profile.Role, &profile.MemberType, &profile.Broadcaster, &profile.CreatedAt, &profile.UpdatedAt) if errors.Is(err, sqldb.ErrNoRows) { return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"} } @@ -187,8 +189,8 @@ func UpdateProfileRole(ctx context.Context, userID uuid.UUID, p *UpdateProfileRo UPDATE user_profiles SET role = $2, updated_at = NOW() WHERE user_id = $1 - RETURNING email, display_name, avatar_url, status, is_artist, created_at, updated_at - `, userID, p.Role).Scan(&profile.Email, &profile.DisplayName, &profile.AvatarURL, &profile.Status, &profile.IsArtist, &profile.CreatedAt, &profile.UpdatedAt) + RETURNING email, display_name, avatar_url, status, member_type, broadcaster, created_at, updated_at + `, userID, p.Role).Scan(&profile.Email, &profile.DisplayName, &profile.AvatarURL, &profile.Status, &profile.MemberType, &profile.Broadcaster, &profile.CreatedAt, &profile.UpdatedAt) if errors.Is(err, sqldb.ErrNoRows) { return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"} } @@ -212,27 +214,122 @@ func UpdateProfilePassword(ctx context.Context, userID uuid.UUID, p *UpdateProfi return nil } -type UpdateProfileArtistParams struct { - IsArtist bool `json:"is_artist"` +type UpdateProfileMemberTypeParams struct { + MemberType string `json:"member_type"` } -// UpdateProfileArtist sets the artist flag of any user's profile. +func (p *UpdateProfileMemberTypeParams) Validate() error { + if !profilessvc.IsValidMemberType(p.MemberType) { + return &errs.Error{Code: errs.InvalidArgument, Message: "invalid member type"} + } + return nil +} + +// UpdateProfileMemberType sets the member type of any user's profile. +// If changing away from broadcaster, disables broadcaster and sets used=false. +// If changing to broadcaster, creates broadcaster with used=true and enabled=false. // -//encore:api auth method=PUT path=/admin/profiles/:userID/artist -func UpdateProfileArtist(ctx context.Context, userID uuid.UUID, p *UpdateProfileArtistParams) (*Profile, error) { - profile := Profile{UserID: userID, IsArtist: p.IsArtist} +//encore:api auth method=PUT path=/admin/profiles/:userID/member-type +func UpdateProfileMemberType(ctx context.Context, userID uuid.UUID, p *UpdateProfileMemberTypeParams) (*Profile, error) { + if err := p.Validate(); err != nil { + return nil, err + } + + profile := Profile{UserID: userID} + var ( + currentMemberType string + currentBroadcaster *uuid.UUID + ) + err := profilesDB.QueryRow(ctx, ` - UPDATE user_profiles - SET is_artist = $2, updated_at = NOW() + SELECT email, display_name, avatar_url, role, status, member_type, broadcaster, created_at, updated_at + FROM user_profiles WHERE user_id = $1 - RETURNING email, display_name, avatar_url, role, status, created_at, updated_at - `, userID, p.IsArtist).Scan(&profile.Email, &profile.DisplayName, &profile.AvatarURL, &profile.Role, &profile.Status, &profile.CreatedAt, &profile.UpdatedAt) + `, userID).Scan( + &profile.Email, + &profile.DisplayName, + &profile.AvatarURL, + &profile.Role, + &profile.Status, + ¤tMemberType, + ¤tBroadcaster, + &profile.CreatedAt, + &profile.UpdatedAt, + ) if errors.Is(err, sqldb.ErrNoRows) { return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"} } if err != nil { - return nil, errs.WrapCode(err, errs.Internal, "failed to update profile artist flag") + return nil, errs.WrapCode(err, errs.Internal, "failed to fetch profile") } + + targetBroadcasterID := currentBroadcaster + + if currentMemberType == profilessvc.MemberTypeBroadcaster && p.MemberType != profilessvc.MemberTypeBroadcaster { + // Moving AWAY from broadcaster: + // "se è broadcaster e si vuole cambiarlo chiede conferma e se affermativa si disabilita e set used false al broadcaster" + _, err := broadcastersDB.Exec(ctx, ` + UPDATE broadcasters + SET enabled = FALSE, used = FALSE, updated_at = NOW() + WHERE user_id = $1 + `, userID) + if err != nil { + return nil, errs.WrapCode(err, errs.Internal, "failed to disable broadcaster") + } + targetBroadcasterID = nil + } else if currentMemberType != profilessvc.MemberTypeBroadcaster && p.MemberType == profilessvc.MemberTypeBroadcaster { + // Moving TO broadcaster: + // "se invece il nuovo tipo si crea il broadcaster con used true enabled false" + var existingID uuid.UUID + err := broadcastersDB.QueryRow(ctx, `SELECT id FROM broadcasters WHERE user_id = $1`, userID).Scan(&existingID) + if err == nil { + _, err = broadcastersDB.Exec(ctx, ` + UPDATE broadcasters + SET used = TRUE, enabled = FALSE, updated_at = NOW() + WHERE id = $1 + `, existingID) + if err != nil { + return nil, errs.WrapCode(err, errs.Internal, "failed to update broadcaster status") + } + targetBroadcasterID = &existingID + } else if errors.Is(err, sqldb.ErrNoRows) { + newID, err := uuid.NewV4() + if err != nil { + return nil, errs.WrapCode(err, errs.Internal, "failed to generate broadcaster id") + } + now := time.Now() + _, err = broadcastersDB.Exec(ctx, ` + INSERT INTO broadcasters ( + id, user_id, display_name, category, category_color, bio, certified, + availability, phone, services, languages, attributes, hours, photos, + enabled, used, created_at, updated_at + ) VALUES ( + $1, $2, $3, 'General', '#63f18eff', '', FALSE, + 'available', '', '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, + FALSE, TRUE, $4, $4 + ) + `, newID, userID, profile.DisplayName, now) + if err != nil { + return nil, errs.WrapCode(err, errs.Internal, "failed to create broadcaster record") + } + targetBroadcasterID = &newID + } else { + return nil, errs.WrapCode(err, errs.Internal, "failed to check existing broadcaster") + } + } + + err = profilesDB.QueryRow(ctx, ` + UPDATE user_profiles + SET member_type = $2, broadcaster = $3, updated_at = NOW() + WHERE user_id = $1 + RETURNING updated_at + `, userID, p.MemberType, targetBroadcasterID).Scan(&profile.UpdatedAt) + if err != nil { + return nil, errs.WrapCode(err, errs.Internal, "failed to update profile member type") + } + + profile.MemberType = p.MemberType + profile.Broadcaster = targetBroadcasterID return &profile, nil } diff --git a/admin/admin_test.go b/admin/admin_test.go new file mode 100644 index 0000000..2bf074b --- /dev/null +++ b/admin/admin_test.go @@ -0,0 +1,114 @@ +package admin + +import ( + "context" + "testing" + + authsvc "encore.app/auth" + profilessvc "encore.app/profiles" + encoreauth "encore.dev/beta/auth" + "encore.dev/et" + "encore.dev/types/uuid" +) + +func TestUpdateProfileMemberTypeBroadcasterLifecycle(t *testing.T) { + adminID, _ := uuid.NewV4() + et.OverrideAuthInfo(encoreauth.UID(adminID.String()), &authsvc.AuthData{Role: "admin"}) + defer et.OverrideAuthInfo("", nil) + + ctx := context.Background() + + // 1. Create a test user profile (initially visitor) + testUserID, err := uuid.NewV4() + if err != nil { + t.Fatalf("failed to generate user id: %v", err) + } + + _, err = profilesDB.Exec(ctx, ` + INSERT INTO user_profiles (user_id, email, display_name, member_type) + VALUES ($1, $2, $3, 'visitor') + `, testUserID, "test-broadcaster-lifecycle@example.com", "Lifecycle Artist") + if err != nil { + t.Fatalf("failed to insert test profile: %v", err) + } + + // 2. Change member_type to 'broadcaster' -> should create broadcaster with used=true, enabled=false + p, err := UpdateProfileMemberType(ctx, testUserID, &UpdateProfileMemberTypeParams{ + MemberType: profilessvc.MemberTypeBroadcaster, + }) + if err != nil { + t.Fatalf("UpdateProfileMemberType to broadcaster failed: %v", err) + } + if p.MemberType != profilessvc.MemberTypeBroadcaster { + t.Errorf("expected member_type 'broadcaster', got '%s'", p.MemberType) + } + if p.Broadcaster == nil { + t.Fatalf("expected profile.Broadcaster to be non-nil") + } + + createdBroadcasterID := *p.Broadcaster + + // Verify in broadcasters DB: used=true, enabled=false + var enabled, used bool + err = broadcastersDB.QueryRow(ctx, `SELECT enabled, used FROM broadcasters WHERE id = $1`, createdBroadcasterID).Scan(&enabled, &used) + if err != nil { + t.Fatalf("failed to query broadcaster: %v", err) + } + if !used { + t.Errorf("expected broadcaster.used to be true, got false") + } + if enabled { + t.Errorf("expected broadcaster.enabled to be false, got true") + } + + // 3. Change member_type from 'broadcaster' to 'visitor' -> should disable broadcaster (enabled=false, used=false) and set profile.broadcaster = nil + p2, err := UpdateProfileMemberType(ctx, testUserID, &UpdateProfileMemberTypeParams{ + MemberType: profilessvc.MemberTypeVisitor, + }) + if err != nil { + t.Fatalf("UpdateProfileMemberType to visitor failed: %v", err) + } + if p2.MemberType != profilessvc.MemberTypeVisitor { + t.Errorf("expected member_type 'visitor', got '%s'", p2.MemberType) + } + if p2.Broadcaster != nil { + t.Errorf("expected profile.Broadcaster to be nil, got %v", p2.Broadcaster) + } + + // Verify in broadcasters DB: used=false, enabled=false + err = broadcastersDB.QueryRow(ctx, `SELECT enabled, used FROM broadcasters WHERE id = $1`, createdBroadcasterID).Scan(&enabled, &used) + if err != nil { + t.Fatalf("failed to query broadcaster: %v", err) + } + if used { + t.Errorf("expected broadcaster.used to be false after type change, got true") + } + if enabled { + t.Errorf("expected broadcaster.enabled to be false after type change, got true") + } + + // 4. Change back to 'broadcaster' -> should reuse existing broadcaster and set used=true, enabled=false + p3, err := UpdateProfileMemberType(ctx, testUserID, &UpdateProfileMemberTypeParams{ + MemberType: profilessvc.MemberTypeBroadcaster, + }) + if err != nil { + t.Fatalf("UpdateProfileMemberType back to broadcaster failed: %v", err) + } + if p3.MemberType != profilessvc.MemberTypeBroadcaster { + t.Errorf("expected member_type 'broadcaster', got '%s'", p3.MemberType) + } + if p3.Broadcaster == nil || *p3.Broadcaster != createdBroadcasterID { + t.Errorf("expected existing broadcaster ID %v, got %v", createdBroadcasterID, p3.Broadcaster) + } + + err = broadcastersDB.QueryRow(ctx, `SELECT enabled, used FROM broadcasters WHERE id = $1`, createdBroadcasterID).Scan(&enabled, &used) + if err != nil { + t.Fatalf("failed to query broadcaster: %v", err) + } + if !used { + t.Errorf("expected broadcaster.used to be true on reactivation, got false") + } + if enabled { + t.Errorf("expected broadcaster.enabled to be false on reactivation, got true") + } +} diff --git a/admin/system.go b/admin/system.go index 23e3c34..b71bc1f 100644 --- a/admin/system.go +++ b/admin/system.go @@ -8,17 +8,20 @@ import ( ) type SystemOptionsResponse struct { - Roles []authsvc.RoleOption `json:"roles"` - Statuses []profilessvc.StatusOption `json:"statuses"` + Roles []authsvc.RoleOption `json:"roles"` + Statuses []profilessvc.StatusOption `json:"statuses"` + MemberTypes []profilessvc.MemberTypeOption `json:"member_types"` } -// GetSystemOptions returns the valid roles and profile statuses, each with a -// display name and underlying value, for use in admin UI dropdowns. +// GetSystemOptions returns the valid roles, profile statuses, and member types, +// each with a display name and underlying value, for use in admin UI dropdowns. // //encore:api auth method=GET path=/admin/system/options func GetSystemOptions(ctx context.Context) (*SystemOptionsResponse, error) { return &SystemOptionsResponse{ - Roles: authsvc.Roles(), - Statuses: profilessvc.Statuses(), + Roles: authsvc.Roles(), + Statuses: profilessvc.Statuses(), + MemberTypes: profilessvc.MemberTypes(), }, nil } + diff --git a/advertising/promo.go b/advertising/promo.go index f5474f9..0be7b76 100644 --- a/advertising/promo.go +++ b/advertising/promo.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "sort" "time" "encore.dev/beta/errs" @@ -346,3 +347,220 @@ func UpdatePromo(ctx context.Context, id int64, p *UpdatePromoParams) (*Promo, e return &promo, nil } + +// PromoLeftGroup raggruppa le promozioni di PromoLeft per sezione ordinata. +type PromoLeftGroup struct { + Week []*Promo `json:"week"` + Raccomended []*Promo `json:"raccomended"` + Discover []*Promo `json:"discover"` +} + +// PromoRight1Group raggruppa le promozioni di PromoRight1 per sezione ordinata. +type PromoRight1Group struct { + Show []*Promo `json:"show"` + Service []*Promo `json:"service"` +} + +// PromoRight2Group raggruppa le promozioni di PromoRight2 per sezione ordinata. +type PromoRight2Group struct { + Top []*Promo `json:"top"` + Selected []*Promo `json:"selected"` +} + +// WeekPromoBySection contiene i tre tipi di promo suddivisi per sezioni ordinate. +type WeekPromoBySection struct { + PromoLeft PromoLeftGroup `json:"promo_left"` + PromoRight1 PromoRight1Group `json:"promo_right1"` + PromoRight2 PromoRight2Group `json:"promo_right2"` +} + +// WeekPromoResponse rappresenta il payload JSON restituito dall'endpoint GetWeekPromo. +type WeekPromoResponse struct { + Week int `json:"week"` + Year int `json:"year"` + PromoLeft []*Promo `json:"promo_left"` + PromoRight1 []*Promo `json:"promo_right1"` + PromoRight2 []*Promo `json:"promo_right2"` + BySection *WeekPromoBySection `json:"by_section"` +} + +// GetWeekPromoParams definisce i parametri query opzionali per GetWeekPromo. +type GetWeekPromoParams struct { + Year int `json:"year,omitempty" query:"year"` +} + +// GetWeekPromoQueryParams definisce i parametri di query per GetCurrentWeekPromo. +type GetWeekPromoQueryParams struct { + Week int `json:"week,omitempty" query:"week"` + Year int `json:"year,omitempty" query:"year"` +} + +// getSectionOrder ritorna la priorità numerica della sezione basata sull'ordine delle costanti in promo.go. +func getSectionOrder(pos PromoPosition, section string) int { + switch pos { + case PromoLeft: + switch PromoLeftSections(section) { + case PromoLeftWeek: + return 1 + case PromoLeftRaccomended: + return 2 + case PromoLeftDiscover: + return 3 + } + case PromoRight1: + switch PromoRight1Sections(section) { + case PromoRight1Show: + return 1 + case PromoRight1Service: + return 2 + } + case PromoRight2: + switch PromoRight2Sections(section) { + case PromoRight2Top: + return 1 + case PromoRight2Selected: + return 2 + } + } + return 99 +} + +// isPromoActiveInWeek verifica se una promozione è valida per la settimana e l'anno specificati. +func isPromoActiveInWeek(p *Promo, targetYear int, targetWeek int) bool { + if targetYear > 0 && p.Year > 0 && p.Year != targetYear { + return false + } + // Se l'array settimane è vuoto, la promozione è attiva per tutto l'anno + if len(p.Weeks) == 0 { + return true + } + for _, w := range p.Weeks { + if w == targetWeek { + return true + } + } + return false +} + +// buildWeekPromoResponse costruisce la risposta con i tre tipi di promo ordinati per sezione. +func buildWeekPromoResponse(ctx context.Context, week int, year int) (*WeekPromoResponse, error) { + if week < 1 || week > 53 { + return nil, &errs.Error{ + Code: errs.InvalidArgument, + Message: "settimana non valida: deve essere compresa tra 1 e 53", + } + } + if year <= 0 { + nowYear, _ := time.Now().ISOWeek() + year = nowYear + } + + allPromos, err := queryPromos(ctx) + if err != nil { + return nil, err + } + + resp := &WeekPromoResponse{ + Week: week, + Year: year, + PromoLeft: make([]*Promo, 0), + PromoRight1: make([]*Promo, 0), + PromoRight2: make([]*Promo, 0), + BySection: &WeekPromoBySection{ + PromoLeft: PromoLeftGroup{ + Week: make([]*Promo, 0), + Raccomended: make([]*Promo, 0), + Discover: make([]*Promo, 0), + }, + PromoRight1: PromoRight1Group{ + Show: make([]*Promo, 0), + Service: make([]*Promo, 0), + }, + PromoRight2: PromoRight2Group{ + Top: make([]*Promo, 0), + Selected: make([]*Promo, 0), + }, + }, + } + + for _, p := range allPromos { + if !isPromoActiveInWeek(p, year, week) { + continue + } + switch p.Position { + case PromoLeft: + resp.PromoLeft = append(resp.PromoLeft, p) + switch PromoLeftSections(p.Section) { + case PromoLeftWeek: + resp.BySection.PromoLeft.Week = append(resp.BySection.PromoLeft.Week, p) + case PromoLeftRaccomended: + resp.BySection.PromoLeft.Raccomended = append(resp.BySection.PromoLeft.Raccomended, p) + case PromoLeftDiscover: + resp.BySection.PromoLeft.Discover = append(resp.BySection.PromoLeft.Discover, p) + } + case PromoRight1: + resp.PromoRight1 = append(resp.PromoRight1, p) + switch PromoRight1Sections(p.Section) { + case PromoRight1Show: + resp.BySection.PromoRight1.Show = append(resp.BySection.PromoRight1.Show, p) + case PromoRight1Service: + resp.BySection.PromoRight1.Service = append(resp.BySection.PromoRight1.Service, p) + } + case PromoRight2: + resp.PromoRight2 = append(resp.PromoRight2, p) + switch PromoRight2Sections(p.Section) { + case PromoRight2Top: + resp.BySection.PromoRight2.Top = append(resp.BySection.PromoRight2.Top, p) + case PromoRight2Selected: + resp.BySection.PromoRight2.Selected = append(resp.BySection.PromoRight2.Selected, p) + } + } + } + + sortPromosBySection := func(promos []*Promo, pos PromoPosition) { + sort.SliceStable(promos, func(i, j int) bool { + ordI := getSectionOrder(pos, promos[i].Section) + ordJ := getSectionOrder(pos, promos[j].Section) + if ordI != ordJ { + return ordI < ordJ + } + return promos[i].ID < promos[j].ID + }) + } + + sortPromosBySection(resp.PromoLeft, PromoLeft) + sortPromosBySection(resp.PromoRight1, PromoRight1) + sortPromosBySection(resp.PromoRight2, PromoRight2) + + return resp, nil +} + +// GetWeekPromo ritorna un JSON con i tre tipi di promo (PromoLeft, PromoRight1, PromoRight2) +// filtrati per la settimana e l'anno specificati, ordinati per le sezioni dichiarate in promo.go. +// +//encore:api public method=GET path=/promo/week/:week +func GetWeekPromo(ctx context.Context, week int, params *GetWeekPromoParams) (*WeekPromoResponse, error) { + year := 0 + if params != nil { + year = params.Year + } + return buildWeekPromoResponse(ctx, week, year) +} + +// GetCurrentWeekPromo ritorna le promozioni della settimana (con settimana e anno opzionali via query ?week=W&year=Y). +// +//encore:api public method=GET path=/promo/week +func GetCurrentWeekPromo(ctx context.Context, params *GetWeekPromoQueryParams) (*WeekPromoResponse, error) { + nowYear, nowWeek := time.Now().ISOWeek() + week := nowWeek + year := nowYear + if params != nil { + if params.Week > 0 { + week = params.Week + } + if params.Year > 0 { + year = params.Year + } + } + return buildWeekPromoResponse(ctx, week, year) +} diff --git a/advertising/promo_test.go b/advertising/promo_test.go index fc5ec48..156c350 100644 --- a/advertising/promo_test.go +++ b/advertising/promo_test.go @@ -212,3 +212,56 @@ func TestUpdatePromoParamsValidate(t *testing.T) { }) } } + +func TestGetSectionOrder(t *testing.T) { + // PromoLeft order: week (1), raccomended (2), discover (3) + if ord := getSectionOrder(PromoLeft, string(PromoLeftWeek)); ord != 1 { + t.Errorf("expected 1 for PromoLeftWeek, got %d", ord) + } + if ord := getSectionOrder(PromoLeft, string(PromoLeftRaccomended)); ord != 2 { + t.Errorf("expected 2 for PromoLeftRaccomended, got %d", ord) + } + if ord := getSectionOrder(PromoLeft, string(PromoLeftDiscover)); ord != 3 { + t.Errorf("expected 3 for PromoLeftDiscover, got %d", ord) + } + if ord := getSectionOrder(PromoLeft, "unknown"); ord != 99 { + t.Errorf("expected 99 for unknown PromoLeft section, got %d", ord) + } + + // PromoRight1 order: show (1), service (2) + if ord := getSectionOrder(PromoRight1, string(PromoRight1Show)); ord != 1 { + t.Errorf("expected 1 for PromoRight1Show, got %d", ord) + } + if ord := getSectionOrder(PromoRight1, string(PromoRight1Service)); ord != 2 { + t.Errorf("expected 2 for PromoRight1Service, got %d", ord) + } + + // PromoRight2 order: top (1), selected (2) + if ord := getSectionOrder(PromoRight2, string(PromoRight2Top)); ord != 1 { + t.Errorf("expected 1 for PromoRight2Top, got %d", ord) + } + if ord := getSectionOrder(PromoRight2, string(PromoRight2Selected)); ord != 2 { + t.Errorf("expected 2 for PromoRight2Selected, got %d", ord) + } +} + +func TestIsPromoActiveInWeek(t *testing.T) { + // All year (empty weeks) + allYear := &Promo{Year: 2026, Weeks: []int{}} + if !isPromoActiveInWeek(allYear, 2026, 10) { + t.Errorf("expected all-year promo to be active in week 10") + } + if isPromoActiveInWeek(allYear, 2025, 10) { + t.Errorf("expected promo for year 2026 to not match year 2025") + } + + // Specific weeks + specificWeeks := &Promo{Year: 2026, Weeks: []int{10, 11, 12}} + if !isPromoActiveInWeek(specificWeeks, 2026, 11) { + t.Errorf("expected promo to be active in week 11") + } + if isPromoActiveInWeek(specificWeeks, 2026, 13) { + t.Errorf("expected promo to NOT be active in week 13") + } +} + diff --git a/broadcasters/broadcasters.go b/broadcasters/broadcasters.go new file mode 100644 index 0000000..ba0073a --- /dev/null +++ b/broadcasters/broadcasters.go @@ -0,0 +1,916 @@ +// Service broadcasters provides APIs and storage for broadcaster profiles and metadata. +package broadcasters + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + authsvc "encore.app/auth" + "encore.app/config" + "encore.dev/beta/auth" + "encore.dev/beta/errs" + "encore.dev/storage/sqldb" + "encore.dev/types/uuid" +) + +var db = sqldb.NewDatabase("broadcasters", sqldb.DatabaseConfig{ + Migrations: "./migrations", +}) + +type StatusResponse struct { + Status string `json:"status"` +} + +// Status verifica lo stato di disponibilità del servizio broadcasters. +// +//encore:api public method=GET path=/broadcasters/status +func Status(ctx context.Context) (*StatusResponse, error) { + return &StatusResponse{Status: "ok"}, nil +} + +type BroadcasterImage struct { + Width int `json:"width"` + Height int `json:"height"` + Proportion string `json:"proportion"` +} + +var BroadcasterImageSize = BroadcasterImage{ + Width: 600, + Height: 0, + Proportion: "2:3", +} + +type BroadcasterPhoto struct { + ImageURL string `json:"image_url"` + Alt string `json:"alt"` +} + +type BroadcasterTimeSlot struct { + From string `json:"from"` + To string `json:"to"` +} + +type BroadcasterHours struct { + Day string `json:"day"` + Hours []BroadcasterTimeSlot `json:"hours"` +} + +func (bh *BroadcasterHours) UnmarshalJSON(data []byte) error { + type rawHours struct { + Day string `json:"day"` + Hours json.RawMessage `json:"hours"` + } + var raw rawHours + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + bh.Day = raw.Day + bh.Hours = []BroadcasterTimeSlot{} + if len(raw.Hours) == 0 { + return nil + } + if err := json.Unmarshal(raw.Hours, &bh.Hours); err == nil { + return nil + } + var str string + if err := json.Unmarshal(raw.Hours, &str); err == nil && str != "" { + parts := strings.Split(str, "-") + if len(parts) == 2 { + bh.Hours = append(bh.Hours, BroadcasterTimeSlot{ + From: strings.TrimSpace(parts[0]), + To: strings.TrimSpace(parts[1]), + }) + } + } + return nil +} + +func ValidateHours(hours []BroadcasterHours) error { + for _, h := range hours { + day := strings.TrimSpace(h.Day) + if day == "" { + return &errs.Error{Code: errs.InvalidArgument, Message: "il giorno non può essere vuoto"} + } + for i := 0; i < len(h.Hours); i++ { + slotA := h.Hours[i] + fromA := strings.TrimSpace(slotA.From) + toA := strings.TrimSpace(slotA.To) + if fromA == "" || toA == "" { + return &errs.Error{Code: errs.InvalidArgument, Message: fmt.Sprintf("orario incompleto per %s: 'from' e 'to' sono obbligatori", day)} + } + if fromA >= toA { + return &errs.Error{Code: errs.InvalidArgument, Message: fmt.Sprintf("orario non valido per %s: l'orario di inizio (%s) deve essere precedente a quello di fine (%s)", day, fromA, toA)} + } + for j := i + 1; j < len(h.Hours); j++ { + slotB := h.Hours[j] + fromB := strings.TrimSpace(slotB.From) + toB := strings.TrimSpace(slotB.To) + if fromA < toB && fromB < toA { + return &errs.Error{ + Code: errs.InvalidArgument, + Message: fmt.Sprintf("orari sovrapposti per %s: la fascia %s-%s si sovrappone con %s-%s", day, fromA, toA, fromB, toB), + } + } + } + } + } + return nil +} + +type BroadcasterAttribute struct { + Label string `json:"label"` + Value string `json:"value"` +} + +type Broadcaster struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + DisplayName string `json:"display_name"` + Category string `json:"category"` + CategoryColor string `json:"category_color"` + Bio string `json:"bio"` + Certified bool `json:"certified"` + Availability string `json:"availability"` + Phone string `json:"phone"` + Services []string `json:"services"` + Languages []string `json:"languages"` + Attributes []BroadcasterAttribute `json:"attributes"` + Hours []BroadcasterHours `json:"hours"` + Photos []BroadcasterPhoto `json:"photos"` + Enabled bool `json:"enabled"` + Used bool `json:"used"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ListBroadcastersParams struct { + Category string `json:"category,omitempty" query:"category"` + Certified bool `json:"certified,omitempty" query:"certified"` + Availability string `json:"availability,omitempty" query:"availability"` + Search string `json:"search,omitempty" query:"search"` +} + +type ListBroadcastersResponse struct { + Broadcasters []*Broadcaster `json:"broadcasters"` + Total int `json:"total"` +} + +type CreateBroadcasterParams struct { + UserID string `json:"user_id,omitempty"` + DisplayName string `json:"display_name"` + Category string `json:"category"` + CategoryColor string `json:"category_color"` + Bio string `json:"bio"` + Certified bool `json:"certified"` + Availability string `json:"availability"` + Phone string `json:"phone"` + Services []string `json:"services"` + Languages []string `json:"languages"` + Attributes []BroadcasterAttribute `json:"attributes"` + Hours []BroadcasterHours `json:"hours"` + Photos []BroadcasterPhoto `json:"photos"` + Enabled *bool `json:"enabled,omitempty"` + Used *bool `json:"used,omitempty"` +} + +func (p *CreateBroadcasterParams) Validate() error { + if p == nil { + return &errs.Error{Code: errs.InvalidArgument, Message: "parametri broadcaster richiesti"} + } + p.DisplayName = strings.TrimSpace(p.DisplayName) + if p.DisplayName == "" { + return &errs.Error{Code: errs.InvalidArgument, Message: "display_name è obbligatorio"} + } + p.Category = strings.TrimSpace(p.Category) + if p.Category == "" { + if len(config.SiteConfig.Categories) > 0 { + p.Category = config.SiteConfig.Categories[0].Name + } else { + p.Category = "General" + } + } + p.CategoryColor = config.GetCategoryColor(p.Category) + if p.Availability == "" { + if opts := config.GetAvailabilityOptions(); len(opts) > 0 { + p.Availability = opts[0] + } else { + p.Availability = "available" + } + } + if p.Services == nil { + p.Services = []string{} + } + if p.Languages == nil { + p.Languages = []string{} + } + if p.Attributes == nil { + p.Attributes = []BroadcasterAttribute{} + } + if p.Hours == nil { + p.Hours = []BroadcasterHours{} + } else { + if err := ValidateHours(p.Hours); err != nil { + return err + } + } + if p.Photos == nil { + p.Photos = []BroadcasterPhoto{} + } + return nil +} + +type UpdateBroadcasterParams struct { + DisplayName string `json:"display_name"` + Category string `json:"category"` + CategoryColor string `json:"category_color"` + Bio string `json:"bio"` + Certified bool `json:"certified"` + Availability string `json:"availability"` + Phone string `json:"phone"` + Services []string `json:"services"` + Languages []string `json:"languages"` + Attributes []BroadcasterAttribute `json:"attributes"` + Hours []BroadcasterHours `json:"hours"` + Photos []BroadcasterPhoto `json:"photos"` + Enabled *bool `json:"enabled,omitempty"` + Used *bool `json:"used,omitempty"` +} + +func (p *UpdateBroadcasterParams) Validate() error { + if p == nil { + return &errs.Error{Code: errs.InvalidArgument, Message: "parametri aggiornamento richiesti"} + } + p.DisplayName = strings.TrimSpace(p.DisplayName) + if p.DisplayName == "" { + return &errs.Error{Code: errs.InvalidArgument, Message: "display_name non può essere vuoto"} + } + p.Category = strings.TrimSpace(p.Category) + if p.Category == "" { + if len(config.SiteConfig.Categories) > 0 { + p.Category = config.SiteConfig.Categories[0].Name + } else { + p.Category = "General" + } + } + p.CategoryColor = config.GetCategoryColor(p.Category) + if p.Availability == "" { + if opts := config.GetAvailabilityOptions(); len(opts) > 0 { + p.Availability = opts[0] + } else { + p.Availability = "available" + } + } + if p.Services == nil { + p.Services = []string{} + } + if p.Languages == nil { + p.Languages = []string{} + } + if p.Hours != nil { + if err := ValidateHours(p.Hours); err != nil { + return err + } + } + return nil +} + +func authedUser() (uuid.UUID, bool, error) { + uid, ok := auth.UserID() + if !ok { + return uuid.Nil, false, &errs.Error{Code: errs.Unauthenticated, Message: "autenticazione richiesta"} + } + parsedID, err := uuid.FromString(string(uid)) + if err != nil { + return uuid.Nil, false, &errs.Error{Code: errs.Unauthenticated, Message: "identificativo utente non valido"} + } + data, _ := auth.Data().(*authsvc.AuthData) + isAdmin := data != nil && data.Role.IsAdmin() + return parsedID, isAdmin, nil +} + +func scanBroadcaster(scanner interface{ Scan(dest ...any) error }) (*Broadcaster, error) { + var ( + b Broadcaster + servicesBytes []byte + languagesBytes []byte + attributesBytes []byte + hoursBytes []byte + photosBytes []byte + ) + + err := scanner.Scan( + &b.ID, + &b.UserID, + &b.DisplayName, + &b.Category, + &b.CategoryColor, + &b.Bio, + &b.Certified, + &b.Availability, + &b.Phone, + &servicesBytes, + &languagesBytes, + &attributesBytes, + &hoursBytes, + &photosBytes, + &b.Enabled, + &b.Used, + &b.CreatedAt, + &b.UpdatedAt, + ) + if err != nil { + return nil, err + } + + b.Services = []string{} + if len(servicesBytes) > 0 { + _ = json.Unmarshal(servicesBytes, &b.Services) + } + + b.Languages = []string{} + if len(languagesBytes) > 0 { + _ = json.Unmarshal(languagesBytes, &b.Languages) + } + + b.Attributes = []BroadcasterAttribute{} + if len(attributesBytes) > 0 { + _ = json.Unmarshal(attributesBytes, &b.Attributes) + } + + b.Hours = []BroadcasterHours{} + if len(hoursBytes) > 0 { + _ = json.Unmarshal(hoursBytes, &b.Hours) + } + + b.Photos = []BroadcasterPhoto{} + if len(photosBytes) > 0 { + _ = json.Unmarshal(photosBytes, &b.Photos) + } + + return &b, nil +} + +// ListBroadcasters restituisce l'elenco dei profili broadcaster con filtri opzionali. +// +//encore:api public method=GET path=/broadcasters +func ListBroadcasters(ctx context.Context, p *ListBroadcastersParams) (*ListBroadcastersResponse, error) { + var ( + whereClauses []string + args []any + argIdx = 1 + ) + + if p != nil { + if cat := strings.TrimSpace(p.Category); cat != "" { + whereClauses = append(whereClauses, fmt.Sprintf("category = $%d", argIdx)) + args = append(args, cat) + argIdx++ + } + if p.Certified { + whereClauses = append(whereClauses, fmt.Sprintf("certified = $%d", argIdx)) + args = append(args, p.Certified) + argIdx++ + } + if avail := strings.TrimSpace(p.Availability); avail != "" { + whereClauses = append(whereClauses, fmt.Sprintf("availability = $%d", argIdx)) + args = append(args, avail) + argIdx++ + } + if q := strings.TrimSpace(p.Search); q != "" { + like := "%" + q + "%" + whereClauses = append(whereClauses, fmt.Sprintf("(display_name ILIKE $%d OR bio ILIKE $%d)", argIdx, argIdx)) + args = append(args, like) + argIdx++ + } + } + + query := ` + SELECT id, user_id, display_name, category, category_color, bio, certified, + availability, phone, services, languages, attributes, hours, photos, + enabled, used, created_at, updated_at + FROM broadcasters + ` + if len(whereClauses) > 0 { + query += " WHERE " + strings.Join(whereClauses, " AND ") + } + query += " ORDER BY certified DESC, display_name ASC, id DESC" + + rows, err := db.Query(ctx, query, args...) + if err != nil { + return nil, errs.WrapCode(err, errs.Internal, "impossibile recuperare i broadcaster") + } + defer rows.Close() + + var result []*Broadcaster + for rows.Next() { + b, err := scanBroadcaster(rows) + if err != nil { + return nil, errs.WrapCode(err, errs.Internal, "errore durante la lettura del record broadcaster") + } + result = append(result, b) + } + if err := rows.Err(); err != nil { + return nil, errs.WrapCode(err, errs.Internal, "errore durante l'iterazione dei broadcaster") + } + + if result == nil { + result = []*Broadcaster{} + } + + return &ListBroadcastersResponse{ + Broadcasters: result, + Total: len(result), + }, nil +} + +// GetBroadcaster restituisce il profilo broadcaster dato il suo ID UUID. +// +//encore:api public method=GET path=/broadcasters/id/:id +func GetBroadcaster(ctx context.Context, id uuid.UUID) (*Broadcaster, error) { + row := db.QueryRow(ctx, ` + SELECT id, user_id, display_name, category, category_color, bio, certified, + availability, phone, services, languages, attributes, hours, photos, + enabled, used, created_at, updated_at + FROM broadcasters + WHERE id = $1 + `, id) + + b, err := scanBroadcaster(row) + if errors.Is(err, sqldb.ErrNoRows) { + return nil, &errs.Error{Code: errs.NotFound, Message: "broadcaster non trovato"} + } + if err != nil { + return nil, errs.WrapCode(err, errs.Internal, "errore durante la lettura del broadcaster") + } + return b, nil +} + +// GetBroadcasterByUserID restituisce il profilo broadcaster associato all'UUID utente. +// +//encore:api public method=GET path=/broadcasters/user/:userID +func GetBroadcasterByUserID(ctx context.Context, userID uuid.UUID) (*Broadcaster, error) { + row := db.QueryRow(ctx, ` + SELECT id, user_id, display_name, category, category_color, bio, certified, + availability, phone, services, languages, attributes, hours, photos, + enabled, used, created_at, updated_at + FROM broadcasters + WHERE user_id = $1 + `, userID) + + b, err := scanBroadcaster(row) + if errors.Is(err, sqldb.ErrNoRows) { + return nil, &errs.Error{Code: errs.NotFound, Message: "broadcaster non trovato per l'utente specificato"} + } + if err != nil { + return nil, errs.WrapCode(err, errs.Internal, "errore durante la lettura del broadcaster") + } + return b, nil +} + +// CreateBroadcaster registra un nuovo profilo broadcaster (richiede autenticazione). +// +//encore:api auth method=POST path=/broadcasters +func CreateBroadcaster(ctx context.Context, p *CreateBroadcasterParams) (*Broadcaster, error) { + callerID, isAdmin, err := authedUser() + if err != nil { + return nil, err + } + + if err := p.Validate(); err != nil { + return nil, err + } + + targetUserID := callerID + if uidStr := strings.TrimSpace(p.UserID); uidStr != "" { + parsedUID, err := uuid.FromString(uidStr) + if err != nil { + return nil, &errs.Error{Code: errs.InvalidArgument, Message: "user_id non valido"} + } + if !isAdmin && parsedUID != callerID { + return nil, &errs.Error{Code: errs.PermissionDenied, Message: "permesso negato: solo gli amministratori possono registrare altri broadcaster"} + } + targetUserID = parsedUID + } + + servicesJSON, _ := json.Marshal(p.Services) + languagesJSON, _ := json.Marshal(p.Languages) + attributesJSON, _ := json.Marshal(p.Attributes) + hoursJSON, _ := json.Marshal(p.Hours) + photosJSON, _ := json.Marshal(p.Photos) + + now := time.Now() + var existingID uuid.UUID + checkErr := db.QueryRow(ctx, `SELECT id FROM broadcasters WHERE user_id = $1`, targetUserID).Scan(&existingID) + if checkErr == nil { + return nil, &errs.Error{ + Code: errs.AlreadyExists, + Message: fmt.Sprintf("esiste già un profilo broadcaster associato a questo utente (ID %s)", existingID), + } + } + + broadcasterID, err := uuid.NewV4() + if err != nil { + return nil, errs.WrapCode(err, errs.Internal, "impossibile generare id broadcaster") + } + + enabled := false + if p.Enabled != nil { + enabled = *p.Enabled + } + used := true + if p.Used != nil { + used = *p.Used + } + + var b Broadcaster + b.ID = broadcasterID + b.UserID = targetUserID + b.DisplayName = p.DisplayName + b.Category = p.Category + b.CategoryColor = p.CategoryColor + b.Bio = p.Bio + b.Certified = p.Certified + b.Availability = p.Availability + b.Phone = p.Phone + b.Services = p.Services + b.Languages = p.Languages + b.Attributes = p.Attributes + b.Hours = p.Hours + b.Photos = p.Photos + b.Enabled = enabled + b.Used = used + b.CreatedAt = now + b.UpdatedAt = now + + query := ` + INSERT INTO broadcasters ( + id, user_id, display_name, category, category_color, bio, certified, + availability, phone, services, languages, attributes, hours, photos, + enabled, used, created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18 + ) + RETURNING created_at, updated_at + ` + err = db.QueryRow(ctx, query, + broadcasterID, targetUserID, p.DisplayName, p.Category, p.CategoryColor, p.Bio, p.Certified, + p.Availability, p.Phone, servicesJSON, languagesJSON, attributesJSON, hoursJSON, photosJSON, + enabled, used, now, now, + ).Scan(&b.CreatedAt, &b.UpdatedAt) + + if err != nil { + return nil, errs.WrapCode(err, errs.Internal, "impossibile creare il profilo broadcaster (potrebbe già esistere)") + } + + return &b, nil +} + +// UpdateBroadcaster aggiorna il profilo di un broadcaster (richiede autenticazione da parte dell'utente o di un admin). +// +//encore:api auth method=PUT path=/broadcasters/id/:id +func UpdateBroadcaster(ctx context.Context, id uuid.UUID, p *UpdateBroadcasterParams) (*Broadcaster, error) { + callerID, isAdmin, err := authedUser() + if err != nil { + return nil, err + } + + if err := p.Validate(); err != nil { + return nil, err + } + + existing, err := GetBroadcaster(ctx, id) + if err != nil { + return nil, err + } + + if !isAdmin && existing.UserID != callerID { + return nil, &errs.Error{Code: errs.PermissionDenied, Message: "permesso negato: non sei il proprietario di questo profilo broadcaster"} + } + + if p.Attributes == nil { + p.Attributes = existing.Attributes + } + if p.Hours == nil { + p.Hours = existing.Hours + } + if p.Photos == nil { + p.Photos = existing.Photos + } + + enabled := existing.Enabled + if p.Enabled != nil { + enabled = *p.Enabled + } + used := existing.Used + if p.Used != nil { + used = *p.Used + } + + servicesJSON, _ := json.Marshal(p.Services) + languagesJSON, _ := json.Marshal(p.Languages) + attributesJSON, _ := json.Marshal(p.Attributes) + hoursJSON, _ := json.Marshal(p.Hours) + photosJSON, _ := json.Marshal(p.Photos) + + now := time.Now() + query := ` + UPDATE broadcasters + SET display_name = $1, category = $2, category_color = $3, bio = $4, + certified = $5, availability = $6, phone = $7, services = $8, + languages = $9, attributes = $10, hours = $11, photos = $12, + enabled = $13, used = $14, updated_at = $15 + WHERE id = $16 + RETURNING updated_at + ` + err = db.QueryRow(ctx, query, + p.DisplayName, p.Category, p.CategoryColor, p.Bio, + p.Certified, p.Availability, p.Phone, servicesJSON, + languagesJSON, attributesJSON, hoursJSON, photosJSON, + enabled, used, now, id, + ).Scan(&existing.UpdatedAt) + + if err != nil { + return nil, errs.WrapCode(err, errs.Internal, "impossibile aggiornare il profilo broadcaster") + } + + existing.DisplayName = p.DisplayName + existing.Category = p.Category + existing.CategoryColor = p.CategoryColor + existing.Bio = p.Bio + existing.Certified = p.Certified + existing.Availability = p.Availability + existing.Phone = p.Phone + existing.Services = p.Services + existing.Languages = p.Languages + existing.Attributes = p.Attributes + existing.Hours = p.Hours + existing.Photos = p.Photos + existing.Enabled = enabled + existing.Used = used + + return existing, nil +} + +// DeleteBroadcaster rimuove il profilo broadcaster (richiede autenticazione dell'utente o di un admin). +// +//encore:api auth method=DELETE path=/broadcasters/id/:id +func DeleteBroadcaster(ctx context.Context, id uuid.UUID) error { + callerID, isAdmin, err := authedUser() + if err != nil { + return err + } + + existing, err := GetBroadcaster(ctx, id) + if err != nil { + return err + } + + if !isAdmin && existing.UserID != callerID { + return &errs.Error{Code: errs.PermissionDenied, Message: "permesso negato: non puoi eliminare questo profilo broadcaster"} + } + + _, err = db.Exec(ctx, `DELETE FROM broadcasters WHERE id = $1`, id) + if err != nil { + return errs.WrapCode(err, errs.Internal, "errore durante l'eliminazione del broadcaster") + } + return nil +} + +type BroadcasterAttributesResponse struct { + Attributes []BroadcasterAttribute `json:"attributes"` +} + +type UpdateBroadcasterAttributesParams struct { + Attributes []BroadcasterAttribute `json:"attributes"` +} + +// GetBroadcasterAttributes restituisce gli attributi configurati per il broadcaster. +// +//encore:api public method=GET path=/broadcasters/id/:id/attributes +func GetBroadcasterAttributes(ctx context.Context, id uuid.UUID) (*BroadcasterAttributesResponse, error) { + b, err := GetBroadcaster(ctx, id) + if err != nil { + return nil, err + } + if b.Attributes == nil { + b.Attributes = []BroadcasterAttribute{} + } + return &BroadcasterAttributesResponse{Attributes: b.Attributes}, nil +} + +// UpdateBroadcasterAttributes aggiorna la lista di attributi del broadcaster. +// +//encore:api auth method=PUT path=/broadcasters/id/:id/attributes +func UpdateBroadcasterAttributes(ctx context.Context, id uuid.UUID, p *UpdateBroadcasterAttributesParams) (*Broadcaster, error) { + callerID, isAdmin, err := authedUser() + if err != nil { + return nil, err + } + + existing, err := GetBroadcaster(ctx, id) + if err != nil { + return nil, err + } + + if !isAdmin && existing.UserID != callerID { + return nil, &errs.Error{Code: errs.PermissionDenied, Message: "permesso negato: non sei il proprietario di questo profilo broadcaster"} + } + + attrs := []BroadcasterAttribute{} + if p != nil && p.Attributes != nil { + attrs = p.Attributes + } + + attributesJSON, _ := json.Marshal(attrs) + now := time.Now() + _, err = db.Exec(ctx, `UPDATE broadcasters SET attributes = $1, updated_at = $2 WHERE id = $3`, attributesJSON, now, id) + if err != nil { + return nil, errs.WrapCode(err, errs.Internal, "impossibile aggiornare gli attributi del broadcaster") + } + + existing.Attributes = attrs + existing.UpdatedAt = now + return existing, nil +} + +type BroadcasterHoursResponse struct { + Hours []BroadcasterHours `json:"hours"` +} + +type UpdateBroadcasterHoursParams struct { + Hours []BroadcasterHours `json:"hours"` +} + +// GetBroadcasterHours restituisce gli orari programmati del broadcaster. +// +//encore:api public method=GET path=/broadcasters/id/:id/hours +func GetBroadcasterHours(ctx context.Context, id uuid.UUID) (*BroadcasterHoursResponse, error) { + b, err := GetBroadcaster(ctx, id) + if err != nil { + return nil, err + } + if b.Hours == nil { + b.Hours = []BroadcasterHours{} + } + return &BroadcasterHoursResponse{Hours: b.Hours}, nil +} + +// UpdateBroadcasterHours aggiorna gli orari del broadcaster. +// +//encore:api auth method=PUT path=/broadcasters/id/:id/hours +func UpdateBroadcasterHours(ctx context.Context, id uuid.UUID, p *UpdateBroadcasterHoursParams) (*Broadcaster, error) { + callerID, isAdmin, err := authedUser() + if err != nil { + return nil, err + } + + existing, err := GetBroadcaster(ctx, id) + if err != nil { + return nil, err + } + + if !isAdmin && existing.UserID != callerID { + return nil, &errs.Error{Code: errs.PermissionDenied, Message: "permesso negato: non sei il proprietario di questo profilo broadcaster"} + } + + hours := []BroadcasterHours{} + if p != nil && p.Hours != nil { + hours = p.Hours + } + + if err := ValidateHours(hours); err != nil { + return nil, err + } + + hoursJSON, _ := json.Marshal(hours) + now := time.Now() + _, err = db.Exec(ctx, `UPDATE broadcasters SET hours = $1, updated_at = $2 WHERE id = $3`, hoursJSON, now, id) + if err != nil { + return nil, errs.WrapCode(err, errs.Internal, "impossibile aggiornare gli orari del broadcaster") + } + + existing.Hours = hours + existing.UpdatedAt = now + return existing, nil +} + +type BroadcasterPhotosResponse struct { + Photos []BroadcasterPhoto `json:"photos"` +} + +type UpdateBroadcasterPhotosParams struct { + Photos []BroadcasterPhoto `json:"photos"` +} + +// GetBroadcasterPhotos restituisce la lista di foto del broadcaster. +// +//encore:api public method=GET path=/broadcasters/id/:id/photos +func GetBroadcasterPhotos(ctx context.Context, id uuid.UUID) (*BroadcasterPhotosResponse, error) { + b, err := GetBroadcaster(ctx, id) + if err != nil { + return nil, err + } + if b.Photos == nil { + b.Photos = []BroadcasterPhoto{} + } + return &BroadcasterPhotosResponse{Photos: b.Photos}, nil +} + +// UpdateBroadcasterPhotos aggiorna la lista di foto del broadcaster. +// +//encore:api auth method=PUT path=/broadcasters/id/:id/photos +func UpdateBroadcasterPhotos(ctx context.Context, id uuid.UUID, p *UpdateBroadcasterPhotosParams) (*Broadcaster, error) { + callerID, isAdmin, err := authedUser() + if err != nil { + return nil, err + } + + existing, err := GetBroadcaster(ctx, id) + if err != nil { + return nil, err + } + + if !isAdmin && existing.UserID != callerID { + return nil, &errs.Error{Code: errs.PermissionDenied, Message: "permesso negato: non sei il proprietario di questo profilo broadcaster"} + } + + photos := []BroadcasterPhoto{} + if p != nil && p.Photos != nil { + photos = p.Photos + } + + photosJSON, _ := json.Marshal(photos) + now := time.Now() + _, err = db.Exec(ctx, `UPDATE broadcasters SET photos = $1, updated_at = $2 WHERE id = $3`, photosJSON, now, id) + if err != nil { + return nil, errs.WrapCode(err, errs.Internal, "impossibile aggiornare le foto del broadcaster") + } + + existing.Photos = photos + existing.UpdatedAt = now + return existing, nil +} + +// GetBroadcasterImageConfig restituisce le dimensioni e proporzioni standard delle immagini dei broadcaster. +// +//encore:api public method=GET path=/broadcasters/image-config +func GetBroadcasterImageConfig(ctx context.Context) (*BroadcasterImage, error) { + return &BroadcasterImageSize, nil +} + +type CategoriesResponse struct { + Categories []config.Category `json:"categories"` +} + +// GetCategories restituisce le categorie configurate in SiteConfig. +// +//encore:api public method=GET path=/broadcasters/categories +func GetCategories(ctx context.Context) (*CategoriesResponse, error) { + return &CategoriesResponse{ + Categories: config.SiteConfig.Categories, + }, nil +} + +type BroadcasterAttributePresetsResponse struct { + Presets []string `json:"presets"` +} + +// GetBroadcasterAttributePresets restituisce i suggerimenti rapidi per gli attributi configurati in SiteConfig. +// +//encore:api public method=GET path=/broadcasters/attribute-presets +func GetBroadcasterAttributePresets(ctx context.Context) (*BroadcasterAttributePresetsResponse, error) { + return &BroadcasterAttributePresetsResponse{ + Presets: config.GetBroadcasterAttributes(), + }, nil +} + +type AvailabilityOptionsResponse struct { + Options []string `json:"options"` +} + +// GetAvailabilityOptions restituisce le opzioni di disponibilità configurate in SiteConfig. +// +//encore:api public method=GET path=/broadcasters/availability-options +func GetAvailabilityOptions(ctx context.Context) (*AvailabilityOptionsResponse, error) { + return &AvailabilityOptionsResponse{ + Options: config.GetAvailabilityOptions(), + }, nil +} + +type BroadcasterServicesResponse struct { + Services []string `json:"services"` +} + +// GetBroadcasterServicesPresets restituisce i suggerimenti per i servizi configurati in SiteConfig. +// +//encore:api public method=GET path=/broadcasters/services-presets +func GetBroadcasterServicesPresets(ctx context.Context) (*BroadcasterServicesResponse, error) { + return &BroadcasterServicesResponse{ + Services: config.GetBroadcasterServices(), + }, nil +} + diff --git a/broadcasters/broadcasters_test.go b/broadcasters/broadcasters_test.go new file mode 100644 index 0000000..0928a1a --- /dev/null +++ b/broadcasters/broadcasters_test.go @@ -0,0 +1,241 @@ +package broadcasters + +import ( + "context" + "testing" + + "encore.app/config" +) + +func TestCreateBroadcasterParamsValidate(t *testing.T) { + tests := []struct { + name string + params *CreateBroadcasterParams + wantErr bool + }{ + { + name: "nil params", + params: nil, + wantErr: true, + }, + { + name: "empty display name", + params: &CreateBroadcasterParams{ + DisplayName: "", + }, + wantErr: true, + }, + { + name: "whitespace display name", + params: &CreateBroadcasterParams{ + DisplayName: " ", + }, + wantErr: true, + }, + { + name: "valid broadcaster params with defaults applied", + params: &CreateBroadcasterParams{ + DisplayName: "DJ Rossi", + }, + wantErr: false, + }, + { + name: "valid full broadcaster params", + params: &CreateBroadcasterParams{ + DisplayName: "Live Streamer Alpha", + Category: "Music", + CategoryColor: "#ec4899", + Bio: "Official channel for live stream sets.", + Certified: true, + Availability: "live", + Phone: "+39 02 1234567", + Services: []string{"Live DJ", "Video chat"}, + Languages: []string{"IT", "EN"}, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.params.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + if err == nil { + if tt.params.Category == "" { + t.Errorf("expected default Category, got empty string") + } + if tt.params.CategoryColor == "" { + t.Errorf("expected default CategoryColor, got empty string") + } + if tt.params.Availability == "" { + t.Errorf("expected default Availability, got empty string") + } + } + }) + } +} + +func TestUpdateBroadcasterParamsValidate(t *testing.T) { + tests := []struct { + name string + params *UpdateBroadcasterParams + wantErr bool + }{ + { + name: "nil update params", + params: nil, + wantErr: true, + }, + { + name: "empty display name in update", + params: &UpdateBroadcasterParams{ + DisplayName: "", + }, + wantErr: true, + }, + { + name: "valid update params", + params: &UpdateBroadcasterParams{ + DisplayName: "New Artist Name", + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.params.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestBroadcasterImageConfig(t *testing.T) { + if BroadcasterImageSize.Width != 600 { + t.Errorf("expected default image width 600, got %d", BroadcasterImageSize.Width) + } + if BroadcasterImageSize.Proportion != "2:3" { + t.Errorf("expected default image proportion 2:3, got %s", BroadcasterImageSize.Proportion) + } +} + +func TestGetCategoriesFromSiteConfig(t *testing.T) { + resp, err := GetCategories(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(resp.Categories) == 0 { + t.Errorf("expected at least 1 category from SiteConfig, got 0") + } + foundGeneral := false + for _, c := range resp.Categories { + if c.Name == "General" { + foundGeneral = true + break + } + } + if !foundGeneral { + t.Errorf("expected to find General category in SiteConfig") + } +} + +func TestValidateHours(t *testing.T) { + // Valid slots + valid := []BroadcasterHours{ + { + Day: "Lunedì", + Hours: []BroadcasterTimeSlot{ + {From: "09:00", To: "12:00"}, + {From: "14:00", To: "18:00"}, + }, + }, + } + if err := ValidateHours(valid); err != nil { + t.Errorf("expected valid hours, got error: %v", err) + } + + // Invalid: From >= To + invalidRange := []BroadcasterHours{ + { + Day: "Martedì", + Hours: []BroadcasterTimeSlot{ + {From: "18:00", To: "12:00"}, + }, + }, + } + if err := ValidateHours(invalidRange); err == nil { + t.Errorf("expected error for from >= to, got nil") + } + + // Invalid: overlapping slots + overlapping := []BroadcasterHours{ + { + Day: "Mercoledì", + Hours: []BroadcasterTimeSlot{ + {From: "10:00", To: "13:00"}, + {From: "12:00", To: "15:00"}, + }, + }, + } + if err := ValidateHours(overlapping); err == nil { + t.Errorf("expected error for overlapping hours, got nil") + } +} + +func TestGetBroadcasterAttributePresetsFromSiteConfig(t *testing.T) { + resp, err := GetBroadcasterAttributePresets(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(resp.Presets) == 0 { + t.Errorf("expected at least 1 attribute preset from SiteConfig, got 0") + } + foundGenre := false + for _, p := range resp.Presets { + if p == "Genere Musicale" { + foundGenre = true + break + } + } + if !foundGenre { + t.Errorf("expected to find 'Genere Musicale' in attribute presets") + } +} + +func TestGetAvailabilityOptionsFromSiteConfig(t *testing.T) { + resp, err := GetAvailabilityOptions(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expected := config.GetAvailabilityOptions() + if len(resp.Options) != len(expected) { + t.Fatalf("expected %d availability options from SiteConfig, got %d", len(expected), len(resp.Options)) + } + for i, opt := range resp.Options { + if opt != expected[i] { + t.Errorf("expected option %d to be '%s', got '%s'", i, expected[i], opt) + } + } +} + +func TestGetBroadcasterServicesPresetsFromSiteConfig(t *testing.T) { + resp, err := GetBroadcasterServicesPresets(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expected := config.GetBroadcasterServices() + if len(resp.Services) != len(expected) { + t.Fatalf("expected %d services presets from SiteConfig, got %d", len(expected), len(resp.Services)) + } + for i, svc := range resp.Services { + if svc != expected[i] { + t.Errorf("expected service %d to be '%s', got '%s'", i, expected[i], svc) + } + } +} + + diff --git a/broadcasters/migrations/1_create_broadcasters.up.sql b/broadcasters/migrations/1_create_broadcasters.up.sql new file mode 100644 index 0000000..71dbbeb --- /dev/null +++ b/broadcasters/migrations/1_create_broadcasters.up.sql @@ -0,0 +1,22 @@ +CREATE TABLE broadcasters ( + id BIGSERIAL PRIMARY KEY, + user_id UUID NOT NULL UNIQUE, + display_name TEXT NOT NULL DEFAULT '', + category TEXT NOT NULL DEFAULT '', + category_color TEXT NOT NULL DEFAULT '#6366f1', + bio TEXT NOT NULL DEFAULT '', + certified BOOLEAN NOT NULL DEFAULT FALSE, + availability TEXT NOT NULL DEFAULT 'available', + phone TEXT NOT NULL DEFAULT '', + services JSONB NOT NULL DEFAULT '[]'::jsonb, + languages JSONB NOT NULL DEFAULT '[]'::jsonb, + attributes JSONB NOT NULL DEFAULT '[]'::jsonb, + hours JSONB NOT NULL DEFAULT '[]'::jsonb, + photos JSONB NOT NULL DEFAULT '[]'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_broadcasters_user_id ON broadcasters(user_id); +CREATE INDEX idx_broadcasters_category ON broadcasters(category); +CREATE INDEX idx_broadcasters_availability ON broadcasters(availability); diff --git a/broadcasters/migrations/2_broadcaster_uuid_and_status.up.sql b/broadcasters/migrations/2_broadcaster_uuid_and_status.up.sql new file mode 100644 index 0000000..ca70567 --- /dev/null +++ b/broadcasters/migrations/2_broadcaster_uuid_and_status.up.sql @@ -0,0 +1,11 @@ +ALTER TABLE broadcasters ADD COLUMN new_id UUID DEFAULT gen_random_uuid(); +UPDATE broadcasters SET new_id = gen_random_uuid() WHERE new_id IS NULL; +ALTER TABLE broadcasters DROP CONSTRAINT broadcasters_pkey; +ALTER TABLE broadcasters DROP COLUMN id; +ALTER TABLE broadcasters RENAME COLUMN new_id TO id; +ALTER TABLE broadcasters ALTER COLUMN id SET NOT NULL; +ALTER TABLE broadcasters ADD PRIMARY KEY (id); + +ALTER TABLE broadcasters ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE broadcasters ADD COLUMN used BOOLEAN NOT NULL DEFAULT FALSE; +UPDATE broadcasters SET used = TRUE, enabled = TRUE; diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..994e4c5 --- /dev/null +++ b/config/config.go @@ -0,0 +1,96 @@ +package config + +import "strings" + +type Category struct { + Name string `json:"name"` + Color string `json:"color"` +} + +type Config struct { + Categories []Category `json:"categories"` + BroadcasterAttributes []string `json:"broadcaster_attributes"` + Availability []string `json:"availability"` + BroadcasterServices []string `json:"broadcaster_services"` +} + +var SiteConfig = Config{ + Categories: []Category{ + { + Name: "General", + Color: "#63f18eff", + }, + { + Name: "Music", + Color: "#f16365ff", + }, + { + Name: "Beauty", + Color: "#6366f1", + }, + { + Name: "test", + Color: "#ecf163ff", + }, + }, + BroadcasterAttributes: []string{ + "Genere Musicale", + "Strumento", + "Anni di Esperienza", + "Città Base", + "Instagram", + "Spotify", + "YouTube", + "SoundCloud", + "Attrezzatura DJ", + "prova attributo", + }, + Availability: []string{ + "incall", + "autcall", + "incall & autcall", + }, + BroadcasterServices: []string{ + "Live DJ", + "Produzioni", + "Vocalist", + "Musicista", + }, +} + +func GetBroadcasterAttributes() []string { + if SiteConfig.BroadcasterAttributes == nil { + return []string{} + } + return SiteConfig.BroadcasterAttributes +} + +func GetBroadcasterServices() []string { + if SiteConfig.BroadcasterServices == nil { + return []string{} + } + return SiteConfig.BroadcasterServices +} + +func GetAvailabilityOptions() []string { + if SiteConfig.Availability == nil { + return []string{} + } + return SiteConfig.Availability +} + +func GetCategory(name string) *Category { + for _, c := range SiteConfig.Categories { + if strings.EqualFold(c.Name, name) { + return &c + } + } + return nil +} + +func GetCategoryColor(name string) string { + if c := GetCategory(name); c != nil && c.Color != "" { + return c.Color + } + return "#6366f1" +} diff --git a/profiles/migrations/12_member_type.up.sql b/profiles/migrations/12_member_type.up.sql new file mode 100644 index 0000000..c5e228c --- /dev/null +++ b/profiles/migrations/12_member_type.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE user_profiles ADD COLUMN member_type TEXT NOT NULL DEFAULT 'visitor'; +UPDATE user_profiles SET member_type = 'broadcaster' WHERE is_artist = TRUE; +ALTER TABLE user_profiles DROP COLUMN is_artist; diff --git a/profiles/migrations/13_add_broadcaster.up.sql b/profiles/migrations/13_add_broadcaster.up.sql new file mode 100644 index 0000000..f6ce991 --- /dev/null +++ b/profiles/migrations/13_add_broadcaster.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE user_profiles ADD COLUMN broadcaster UUID; +CREATE INDEX idx_user_profiles_broadcaster ON user_profiles(broadcaster); diff --git a/profiles/profiles.go b/profiles/profiles.go index 6a74184..f9f9b85 100644 --- a/profiles/profiles.go +++ b/profiles/profiles.go @@ -23,16 +23,49 @@ var db = sqldb.NewDatabase("profiles", sqldb.DatabaseConfig{ var authDB = sqldb.Named("auth") +const ( + MemberTypeBroadcaster = "broadcaster" + MemberTypeVisitor = "visitor" + MemberTypeAffiliate = "affiliate" + MemberTypeAdvertiser = "advertiser" + MemberTypeAdmin = "admin" +) + +type MemberTypeOption struct { + Name string `json:"name"` + Value string `json:"value"` +} + +func MemberTypes() []MemberTypeOption { + return []MemberTypeOption{ + {Name: "Broadcaster", Value: MemberTypeBroadcaster}, + {Name: "Visitor", Value: MemberTypeVisitor}, + {Name: "Affiliate", Value: MemberTypeAffiliate}, + {Name: "Advertiser", Value: MemberTypeAdvertiser}, + {Name: "Admin", Value: MemberTypeAdmin}, + } +} + +func IsValidMemberType(value string) bool { + switch value { + case MemberTypeBroadcaster, MemberTypeVisitor, MemberTypeAffiliate, MemberTypeAdvertiser, MemberTypeAdmin: + return true + default: + return false + } +} + type Profile struct { - UserID uuid.UUID `json:"user_id"` - Email string `json:"email"` - DisplayName string `json:"display_name"` - AvatarURL string `json:"avatar_url"` - IsArtist bool `json:"is_artist"` - Role string `json:"role"` - Status Status `json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + UserID uuid.UUID `json:"user_id"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + AvatarURL string `json:"avatar_url"` + MemberType string `json:"member_type"` + Broadcaster *uuid.UUID `json:"broadcaster"` + Role string `json:"role"` + Status Status `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type ProfileParams struct { @@ -123,8 +156,8 @@ func Insert(ctx context.Context, p *RegisterParams) (*Profile, error) { err = db.QueryRow(ctx, ` INSERT INTO user_profiles (user_id, email, display_name, avatar_url) VALUES ($1, $2, $3, $4) - RETURNING role, status, is_artist, created_at, updated_at - `, userID, p.Email, p.DisplayName, p.AvatarURL).Scan(&profile.Role, &profile.Status, &profile.IsArtist, &profile.CreatedAt, &profile.UpdatedAt) + RETURNING role, status, member_type, broadcaster, created_at, updated_at + `, userID, p.Email, p.DisplayName, p.AvatarURL).Scan(&profile.Role, &profile.Status, &profile.MemberType, &profile.Broadcaster, &profile.CreatedAt, &profile.UpdatedAt) if isUniqueViolation(err) { return nil, &errs.Error{Code: errs.AlreadyExists, Message: "email already registered"} } @@ -160,8 +193,8 @@ func Update(ctx context.Context, p *ProfileParams) (*Profile, error) { UPDATE user_profiles SET display_name = $2, avatar_url = $3, updated_at = NOW() WHERE user_id = $1 - RETURNING email, role, status, is_artist, created_at, updated_at - `, userID, p.DisplayName, p.AvatarURL).Scan(&profile.Email, &profile.Role, &profile.Status, &profile.IsArtist, &profile.CreatedAt, &profile.UpdatedAt) + RETURNING email, role, status, member_type, broadcaster, created_at, updated_at + `, userID, p.DisplayName, p.AvatarURL).Scan(&profile.Email, &profile.Role, &profile.Status, &profile.MemberType, &profile.Broadcaster, &profile.CreatedAt, &profile.UpdatedAt) if errors.Is(err, sqldb.ErrNoRows) { return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"} } @@ -186,8 +219,8 @@ func Get(ctx context.Context) (*Profile, error) { } p := Profile{UserID: userID} err = db.QueryRow(ctx, ` - SELECT email, display_name, avatar_url, role, status, is_artist, created_at, updated_at FROM user_profiles WHERE user_id = $1 - `, userID).Scan(&p.Email, &p.DisplayName, &p.AvatarURL, &p.Role, &p.Status, &p.IsArtist, &p.CreatedAt, &p.UpdatedAt) + SELECT email, display_name, avatar_url, role, status, member_type, broadcaster, created_at, updated_at FROM user_profiles WHERE user_id = $1 + `, userID).Scan(&p.Email, &p.DisplayName, &p.AvatarURL, &p.Role, &p.Status, &p.MemberType, &p.Broadcaster, &p.CreatedAt, &p.UpdatedAt) if errors.Is(err, sqldb.ErrNoRows) { return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"} } diff --git a/registration/registration.go b/registration/registration.go index 6a6ac12..58f6bfd 100644 --- a/registration/registration.go +++ b/registration/registration.go @@ -225,7 +225,7 @@ func RequestPasswordReset(ctx context.Context, p *PasswordResetRequestParams) (* var profile profilessvc.Profile err = profilesDB.QueryRow(ctx, ` - SELECT user_id, email, display_name, avatar_url, role, status, is_artist, created_at, updated_at + SELECT user_id, email, display_name, avatar_url, role, status, member_type, created_at, updated_at FROM user_profiles WHERE email = $1 `, email).Scan( @@ -235,7 +235,7 @@ func RequestPasswordReset(ctx context.Context, p *PasswordResetRequestParams) (* &profile.AvatarURL, &profile.Role, &profile.Status, - &profile.IsArtist, + &profile.MemberType, &profile.CreatedAt, &profile.UpdatedAt, )