some changes
This commit is contained in:
145
admin/admin.go
145
admin/admin.go
@@ -15,13 +15,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var profilesDB = sqldb.Named("profiles")
|
var profilesDB = sqldb.Named("profiles")
|
||||||
|
var broadcastersDB = sqldb.Named("broadcasters")
|
||||||
|
|
||||||
type Profile struct {
|
type Profile struct {
|
||||||
UserID uuid.UUID `json:"user_id"`
|
UserID uuid.UUID `json:"user_id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
AvatarURL string `json:"avatar_url"`
|
AvatarURL string `json:"avatar_url"`
|
||||||
IsArtist bool `json:"is_artist"`
|
MemberType string `json:"member_type"`
|
||||||
|
Broadcaster *uuid.UUID `json:"broadcaster"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Status profilessvc.Status `json:"status"`
|
Status profilessvc.Status `json:"status"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
@@ -49,7 +51,7 @@ type ListProfilesResponse struct {
|
|||||||
//encore:api auth method=GET path=/admin/profiles
|
//encore:api auth method=GET path=/admin/profiles
|
||||||
func ListProfiles(ctx context.Context) (*ListProfilesResponse, error) {
|
func ListProfiles(ctx context.Context) (*ListProfilesResponse, error) {
|
||||||
rows, err := profilesDB.Query(ctx, `
|
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
|
FROM user_profiles
|
||||||
ORDER BY user_id
|
ORDER BY user_id
|
||||||
`)
|
`)
|
||||||
@@ -61,7 +63,7 @@ func ListProfiles(ctx context.Context) (*ListProfilesResponse, error) {
|
|||||||
profiles := []*Profile{}
|
profiles := []*Profile{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var p Profile
|
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")
|
return nil, errs.WrapCode(err, errs.Internal, "failed to scan profile")
|
||||||
}
|
}
|
||||||
profiles = append(profiles, &p)
|
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) {
|
func GetProfile(ctx context.Context, userID uuid.UUID) (*Profile, error) {
|
||||||
p := Profile{UserID: userID}
|
p := Profile{UserID: userID}
|
||||||
err := profilesDB.QueryRow(ctx, `
|
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
|
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.IsArtist, &p.CreatedAt, &p.UpdatedAt)
|
`, 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) {
|
if errors.Is(err, sqldb.ErrNoRows) {
|
||||||
return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"}
|
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, `
|
err = profilesDB.QueryRow(ctx, `
|
||||||
INSERT INTO user_profiles (user_id, email, display_name, avatar_url)
|
INSERT INTO user_profiles (user_id, email, display_name, avatar_url)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING role, status, is_artist, created_at, updated_at
|
RETURNING role, status, member_type, broadcaster, created_at, updated_at
|
||||||
`, userID, p.Email, p.DisplayName, p.AvatarURL).Scan(&profile.Role, &profile.Status, &profile.IsArtist, &profile.CreatedAt, &profile.UpdatedAt)
|
`, userID, p.Email, p.DisplayName, p.AvatarURL).Scan(&profile.Role, &profile.Status, &profile.MemberType, &profile.Broadcaster, &profile.CreatedAt, &profile.UpdatedAt)
|
||||||
if isUniqueViolation(err) {
|
if isUniqueViolation(err) {
|
||||||
return nil, &errs.Error{Code: errs.AlreadyExists, Message: "email already registered"}
|
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
|
UPDATE user_profiles
|
||||||
SET display_name = $2, avatar_url = $3, updated_at = NOW()
|
SET display_name = $2, avatar_url = $3, updated_at = NOW()
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
RETURNING email, role, status, is_artist, created_at, updated_at
|
RETURNING email, role, status, member_type, broadcaster, created_at, updated_at
|
||||||
`, userID, p.DisplayName, p.AvatarURL).Scan(&profile.Email, &profile.Role, &profile.Status, &profile.IsArtist, &profile.CreatedAt, &profile.UpdatedAt)
|
`, 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) {
|
if errors.Is(err, sqldb.ErrNoRows) {
|
||||||
return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"}
|
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
|
UPDATE user_profiles
|
||||||
SET status = $2, updated_at = NOW()
|
SET status = $2, updated_at = NOW()
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
RETURNING email, display_name, avatar_url, role, is_artist, created_at, updated_at
|
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.IsArtist, &profile.CreatedAt, &profile.UpdatedAt)
|
`, 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) {
|
if errors.Is(err, sqldb.ErrNoRows) {
|
||||||
return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"}
|
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
|
UPDATE user_profiles
|
||||||
SET role = $2, updated_at = NOW()
|
SET role = $2, updated_at = NOW()
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
RETURNING email, display_name, avatar_url, status, is_artist, created_at, updated_at
|
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.IsArtist, &profile.CreatedAt, &profile.UpdatedAt)
|
`, 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) {
|
if errors.Is(err, sqldb.ErrNoRows) {
|
||||||
return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"}
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateProfileArtistParams struct {
|
type UpdateProfileMemberTypeParams struct {
|
||||||
IsArtist bool `json:"is_artist"`
|
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
|
//encore:api auth method=PUT path=/admin/profiles/:userID/member-type
|
||||||
func UpdateProfileArtist(ctx context.Context, userID uuid.UUID, p *UpdateProfileArtistParams) (*Profile, error) {
|
func UpdateProfileMemberType(ctx context.Context, userID uuid.UUID, p *UpdateProfileMemberTypeParams) (*Profile, error) {
|
||||||
profile := Profile{UserID: userID, IsArtist: p.IsArtist}
|
if err := p.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
profile := Profile{UserID: userID}
|
||||||
|
var (
|
||||||
|
currentMemberType string
|
||||||
|
currentBroadcaster *uuid.UUID
|
||||||
|
)
|
||||||
|
|
||||||
err := profilesDB.QueryRow(ctx, `
|
err := profilesDB.QueryRow(ctx, `
|
||||||
UPDATE user_profiles
|
SELECT email, display_name, avatar_url, role, status, member_type, broadcaster, created_at, updated_at
|
||||||
SET is_artist = $2, updated_at = NOW()
|
FROM user_profiles
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
RETURNING email, display_name, avatar_url, role, status, created_at, updated_at
|
`, userID).Scan(
|
||||||
`, userID, p.IsArtist).Scan(&profile.Email, &profile.DisplayName, &profile.AvatarURL, &profile.Role, &profile.Status, &profile.CreatedAt, &profile.UpdatedAt)
|
&profile.Email,
|
||||||
|
&profile.DisplayName,
|
||||||
|
&profile.AvatarURL,
|
||||||
|
&profile.Role,
|
||||||
|
&profile.Status,
|
||||||
|
¤tMemberType,
|
||||||
|
¤tBroadcaster,
|
||||||
|
&profile.CreatedAt,
|
||||||
|
&profile.UpdatedAt,
|
||||||
|
)
|
||||||
if errors.Is(err, sqldb.ErrNoRows) {
|
if errors.Is(err, sqldb.ErrNoRows) {
|
||||||
return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"}
|
return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"}
|
||||||
}
|
}
|
||||||
if err != nil {
|
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
|
return &profile, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
114
admin/admin_test.go
Normal file
114
admin/admin_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,15 +10,18 @@ import (
|
|||||||
type SystemOptionsResponse struct {
|
type SystemOptionsResponse struct {
|
||||||
Roles []authsvc.RoleOption `json:"roles"`
|
Roles []authsvc.RoleOption `json:"roles"`
|
||||||
Statuses []profilessvc.StatusOption `json:"statuses"`
|
Statuses []profilessvc.StatusOption `json:"statuses"`
|
||||||
|
MemberTypes []profilessvc.MemberTypeOption `json:"member_types"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSystemOptions returns the valid roles and profile statuses, each with a
|
// GetSystemOptions returns the valid roles, profile statuses, and member types,
|
||||||
// display name and underlying value, for use in admin UI dropdowns.
|
// each with a display name and underlying value, for use in admin UI dropdowns.
|
||||||
//
|
//
|
||||||
//encore:api auth method=GET path=/admin/system/options
|
//encore:api auth method=GET path=/admin/system/options
|
||||||
func GetSystemOptions(ctx context.Context) (*SystemOptionsResponse, error) {
|
func GetSystemOptions(ctx context.Context) (*SystemOptionsResponse, error) {
|
||||||
return &SystemOptionsResponse{
|
return &SystemOptionsResponse{
|
||||||
Roles: authsvc.Roles(),
|
Roles: authsvc.Roles(),
|
||||||
Statuses: profilessvc.Statuses(),
|
Statuses: profilessvc.Statuses(),
|
||||||
|
MemberTypes: profilessvc.MemberTypes(),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"encore.dev/beta/errs"
|
"encore.dev/beta/errs"
|
||||||
@@ -346,3 +347,220 @@ func UpdatePromo(ctx context.Context, id int64, p *UpdatePromoParams) (*Promo, e
|
|||||||
|
|
||||||
return &promo, nil
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
916
broadcasters/broadcasters.go
Normal file
916
broadcasters/broadcasters.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
241
broadcasters/broadcasters_test.go
Normal file
241
broadcasters/broadcasters_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
22
broadcasters/migrations/1_create_broadcasters.up.sql
Normal file
22
broadcasters/migrations/1_create_broadcasters.up.sql
Normal file
@@ -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);
|
||||||
11
broadcasters/migrations/2_broadcaster_uuid_and_status.up.sql
Normal file
11
broadcasters/migrations/2_broadcaster_uuid_and_status.up.sql
Normal file
@@ -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;
|
||||||
96
config/config.go
Normal file
96
config/config.go
Normal file
@@ -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"
|
||||||
|
}
|
||||||
3
profiles/migrations/12_member_type.up.sql
Normal file
3
profiles/migrations/12_member_type.up.sql
Normal file
@@ -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;
|
||||||
2
profiles/migrations/13_add_broadcaster.up.sql
Normal file
2
profiles/migrations/13_add_broadcaster.up.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE user_profiles ADD COLUMN broadcaster UUID;
|
||||||
|
CREATE INDEX idx_user_profiles_broadcaster ON user_profiles(broadcaster);
|
||||||
@@ -23,12 +23,45 @@ var db = sqldb.NewDatabase("profiles", sqldb.DatabaseConfig{
|
|||||||
|
|
||||||
var authDB = sqldb.Named("auth")
|
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 {
|
type Profile struct {
|
||||||
UserID uuid.UUID `json:"user_id"`
|
UserID uuid.UUID `json:"user_id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
AvatarURL string `json:"avatar_url"`
|
AvatarURL string `json:"avatar_url"`
|
||||||
IsArtist bool `json:"is_artist"`
|
MemberType string `json:"member_type"`
|
||||||
|
Broadcaster *uuid.UUID `json:"broadcaster"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Status Status `json:"status"`
|
Status Status `json:"status"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
@@ -123,8 +156,8 @@ func Insert(ctx context.Context, p *RegisterParams) (*Profile, error) {
|
|||||||
err = db.QueryRow(ctx, `
|
err = db.QueryRow(ctx, `
|
||||||
INSERT INTO user_profiles (user_id, email, display_name, avatar_url)
|
INSERT INTO user_profiles (user_id, email, display_name, avatar_url)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING role, status, is_artist, created_at, updated_at
|
RETURNING role, status, member_type, broadcaster, created_at, updated_at
|
||||||
`, userID, p.Email, p.DisplayName, p.AvatarURL).Scan(&profile.Role, &profile.Status, &profile.IsArtist, &profile.CreatedAt, &profile.UpdatedAt)
|
`, userID, p.Email, p.DisplayName, p.AvatarURL).Scan(&profile.Role, &profile.Status, &profile.MemberType, &profile.Broadcaster, &profile.CreatedAt, &profile.UpdatedAt)
|
||||||
if isUniqueViolation(err) {
|
if isUniqueViolation(err) {
|
||||||
return nil, &errs.Error{Code: errs.AlreadyExists, Message: "email already registered"}
|
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
|
UPDATE user_profiles
|
||||||
SET display_name = $2, avatar_url = $3, updated_at = NOW()
|
SET display_name = $2, avatar_url = $3, updated_at = NOW()
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
RETURNING email, role, status, is_artist, created_at, updated_at
|
RETURNING email, role, status, member_type, broadcaster, created_at, updated_at
|
||||||
`, userID, p.DisplayName, p.AvatarURL).Scan(&profile.Email, &profile.Role, &profile.Status, &profile.IsArtist, &profile.CreatedAt, &profile.UpdatedAt)
|
`, 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) {
|
if errors.Is(err, sqldb.ErrNoRows) {
|
||||||
return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"}
|
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}
|
p := Profile{UserID: userID}
|
||||||
err = db.QueryRow(ctx, `
|
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
|
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.IsArtist, &p.CreatedAt, &p.UpdatedAt)
|
`, 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) {
|
if errors.Is(err, sqldb.ErrNoRows) {
|
||||||
return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"}
|
return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ func RequestPasswordReset(ctx context.Context, p *PasswordResetRequestParams) (*
|
|||||||
|
|
||||||
var profile profilessvc.Profile
|
var profile profilessvc.Profile
|
||||||
err = profilesDB.QueryRow(ctx, `
|
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
|
FROM user_profiles
|
||||||
WHERE email = $1
|
WHERE email = $1
|
||||||
`, email).Scan(
|
`, email).Scan(
|
||||||
@@ -235,7 +235,7 @@ func RequestPasswordReset(ctx context.Context, p *PasswordResetRequestParams) (*
|
|||||||
&profile.AvatarURL,
|
&profile.AvatarURL,
|
||||||
&profile.Role,
|
&profile.Role,
|
||||||
&profile.Status,
|
&profile.Status,
|
||||||
&profile.IsArtist,
|
&profile.MemberType,
|
||||||
&profile.CreatedAt,
|
&profile.CreatedAt,
|
||||||
&profile.UpdatedAt,
|
&profile.UpdatedAt,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user