feat(advertising): implement advertising service with CRUD operations and validation

- Added advertising.go for managing advertising entries with Create and List functionalities.
- Implemented CreateAdvertisingParams with validation for name, email, and status.
- Created migrations for advertising table structure.
- Added tests for advertising parameters validation.

feat(promo): introduce promo management with validation and CRUD operations

- Added promo.go for managing promotional entries with Create, Update, and List functionalities.
- Implemented CreatePromoParams and UpdatePromoParams with validation for position, section, and weeks.
- Created migrations for promo table structure.
- Added tests for promo parameters validation.

feat(images): add image management service with upload and retrieval

- Added images.go for managing image uploads and retrieval from storage.
- Implemented SaveImageParams with validation for image description and data.
- Created migrations for images table structure.
- Added tests for image parameters validation and data parsing.
This commit is contained in:
fabio
2026-10-01 20:19:07 +02:00
parent 0fe1bc2292
commit 664db8f7a0
17 changed files with 1299 additions and 1382 deletions

252
images/images.go Normal file
View File

@@ -0,0 +1,252 @@
// Service images gestisce il catalogo e lo storage delle immagini.
package images
import (
"bytes"
"context"
"encoding/base64"
"io"
"net/http"
"strings"
"time"
"encore.dev/beta/errs"
"encore.dev/storage/objects"
"encore.dev/storage/sqldb"
"encore.dev/types/uuid"
)
var db = sqldb.NewDatabase("images", sqldb.DatabaseConfig{
Migrations: "./migrations",
})
// imagesBucket stores image files in Encore object storage.
var imagesBucket = objects.NewBucket("images", objects.BucketConfig{
Public: true,
})
// ImageCategory categorizza le immagini caricate.
type ImageCategory string
const (
ImageCategoryGeneral ImageCategory = "general"
ImageCategoryAvatar ImageCategory = "avatar"
ImageCategoryPromo ImageCategory = "promo"
ImageCategoryBanner ImageCategory = "banner"
)
// Image rappresenta una risorsa immagine salvata nel database e nello storage.
type Image struct {
UUID uuid.UUID `json:"uuid"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
URL string `json:"url"`
Proportion string `json:"proportion"`
Width int `json:"width"`
Height int `json:"height"`
}
type ListImagesResponse struct {
Images []*Image `json:"images"`
}
type SaveImageParams struct {
UUID string `json:"uuid,omitempty"`
Description string `json:"description"`
Data string `json:"data,omitempty"`
ContentType string `json:"content_type,omitempty"`
Proportion string `json:"proportion,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
}
func (p *SaveImageParams) Validate() error {
if p == nil {
return &errs.Error{Code: errs.InvalidArgument, Message: "i parametri immagine sono obbligatori"}
}
if strings.TrimSpace(p.Description) == "" {
return &errs.Error{Code: errs.InvalidArgument, Message: "la descrizione dell'immagine è obbligatoria"}
}
if strings.TrimSpace(p.UUID) == "" && strings.TrimSpace(p.Data) == "" {
return &errs.Error{Code: errs.InvalidArgument, Message: "il contenuto dell'immagine (data) è obbligatorio per una nuova immagine"}
}
return nil
}
func parseImageData(data string, fallbackContentType string) ([]byte, string, error) {
data = strings.TrimSpace(data)
contentType := fallbackContentType
if contentType == "" {
contentType = "image/png"
}
if strings.HasPrefix(data, "data:") {
parts := strings.SplitN(data, ",", 2)
if len(parts) == 2 {
header := parts[0]
data = parts[1]
if strings.Contains(header, ";") {
ct := strings.TrimPrefix(strings.Split(header, ";")[0], "data:")
if ct != "" {
contentType = ct
}
}
}
}
decoded, err := base64.StdEncoding.DecodeString(data)
if err != nil {
return nil, "", errs.WrapCode(err, errs.InvalidArgument, "dati immagine base64 non validi")
}
return decoded, contentType, nil
}
// ListImages ritorna la lista completa delle immagini.
//
//encore:api public method=GET path=/images
func ListImages(ctx context.Context) (*ListImagesResponse, error) {
rows, err := db.Query(ctx, `
SELECT uuid, description, created_at
FROM images
ORDER BY created_at DESC
`)
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "errore durante il recupero delle immagini")
}
defer rows.Close()
var list []*Image
for rows.Next() {
var img Image
if err := rows.Scan(&img.UUID, &img.Description, &img.CreatedAt); err != nil {
return nil, errs.WrapCode(err, errs.Internal, "errore durante la lettura del record immagine")
}
img.URL = imagesBucket.PublicURL(img.UUID.String()).String()
list = append(list, &img)
}
if err := rows.Err(); err != nil {
return nil, errs.WrapCode(err, errs.Internal, "errore durante l'iterazione delle immagini")
}
if list == nil {
list = []*Image{}
}
return &ListImagesResponse{Images: list}, nil
}
// GetImage ritorna lo stream binario dell'immagine memorizzata nello store.
//
//encore:api public raw method=GET path=/image/:uuid
func GetImage(w http.ResponseWriter, req *http.Request) {
uuidStr := req.PathValue("uuid")
if uuidStr == "" {
uuidStr = strings.TrimPrefix(req.URL.Path, "/image/")
}
uuidStr = strings.TrimSpace(uuidStr)
parsedUUID, err := uuid.FromString(uuidStr)
if err != nil {
errs.HTTPError(w, &errs.Error{Code: errs.InvalidArgument, Message: "UUID immagine non valido"})
return
}
reader := imagesBucket.Download(req.Context(), parsedUUID.String())
defer reader.Close()
w.Header().Set("Content-Type", "image/png")
if _, err := io.Copy(w, reader); err != nil {
if reader.Err() != nil {
errs.HTTPError(w, &errs.Error{Code: errs.NotFound, Message: "immagine non trovata"})
return
}
}
if reader.Err() != nil {
errs.HTTPError(w, &errs.Error{Code: errs.NotFound, Message: "immagine non trovata"})
return
}
}
// SaveImage crea una nuova immagine o aggiorna la descrizione/file dell'immagine esistente.
//
//encore:api public method=POST path=/image
func SaveImage(ctx context.Context, p *SaveImageParams) (*Image, error) {
if err := p.Validate(); err != nil {
return nil, err
}
var targetUUID uuid.UUID
var isUpdate bool
uuidStr := strings.TrimSpace(p.UUID)
if uuidStr != "" {
parsedUUID, err := uuid.FromString(uuidStr)
if err != nil {
return nil, &errs.Error{Code: errs.InvalidArgument, Message: "UUID immagine non valido"}
}
targetUUID = parsedUUID
var exists bool
err = db.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM images WHERE uuid = $1)`, targetUUID).Scan(&exists)
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "errore durante il controllo esistenza immagine")
}
isUpdate = exists
} else {
newID, err := uuid.NewV4()
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "impossibile generare l'identificativo UUID")
}
targetUUID = newID
}
if strings.TrimSpace(p.Data) != "" {
rawBytes, detectedContentType, err := parseImageData(p.Data, p.ContentType)
if err != nil {
return nil, err
}
writer := imagesBucket.Upload(ctx, targetUUID.String(), objects.WithUploadAttrs(objects.UploadAttrs{
ContentType: detectedContentType,
}))
if _, err := io.Copy(writer, bytes.NewReader(rawBytes)); err != nil {
writer.Abort(err)
return nil, errs.WrapCode(err, errs.Internal, "errore durante il salvataggio dell'immagine nello store")
}
if err := writer.Close(); err != nil {
return nil, errs.WrapCode(err, errs.Internal, "errore durante la chiusura dello store immagine")
}
}
var createdAt time.Time
if isUpdate {
err := db.QueryRow(ctx, `
UPDATE images
SET description = $1
WHERE uuid = $2
RETURNING created_at
`, strings.TrimSpace(p.Description), targetUUID).Scan(&createdAt)
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "impossibile aggiornare l'immagine nel database")
}
} else {
err := db.QueryRow(ctx, `
INSERT INTO images (uuid, description)
VALUES ($1, $2)
RETURNING created_at
`, targetUUID, strings.TrimSpace(p.Description)).Scan(&createdAt)
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "impossibile creare il record immagine nel database")
}
}
return &Image{
UUID: targetUUID,
Description: strings.TrimSpace(p.Description),
CreatedAt: createdAt,
URL: imagesBucket.PublicURL(targetUUID.String()).String(),
Proportion: p.Proportion,
Width: p.Width,
Height: p.Height,
}, nil
}