some changes

This commit is contained in:
fabio
2026-10-06 09:55:38 +02:00
parent 664db8f7a0
commit ff6c30d205
14 changed files with 1856 additions and 47 deletions

View 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
}

View 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)
}
}
}

View 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);

View 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;