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
}

94
images/images_test.go Normal file
View File

@@ -0,0 +1,94 @@
package images
import (
"encoding/base64"
"testing"
"encore.dev/types/uuid"
)
func TestSaveImageParams_Validate(t *testing.T) {
// Missing description
p := &SaveImageParams{
Description: "",
Data: "abc",
}
if err := p.Validate(); err == nil {
t.Error("expected error for empty description, got nil")
}
// Missing data for new image (uuid is empty)
p = &SaveImageParams{
UUID: "",
Description: "A test image",
Data: "",
}
if err := p.Validate(); err == nil {
t.Error("expected error for empty data on new image, got nil")
}
// Valid new image (uuid is empty string)
p = &SaveImageParams{
UUID: "",
Description: "A test image",
Data: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
}
if err := p.Validate(); err != nil {
t.Errorf("expected valid params, got err: %v", err)
}
// Valid update (uuid present, empty data is allowed)
id, _ := uuid.NewV4()
p = &SaveImageParams{
UUID: id.String(),
Description: "Updated description",
}
if err := p.Validate(); err != nil {
t.Errorf("expected valid update params, got err: %v", err)
}
}
func TestParseImageData(t *testing.T) {
raw := []byte("hello world")
encoded := base64.StdEncoding.EncodeToString(raw)
// Plain base64
data, ct, err := parseImageData(encoded, "image/jpeg")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if string(data) != "hello world" {
t.Errorf("expected 'hello world', got %q", string(data))
}
if ct != "image/jpeg" {
t.Errorf("expected 'image/jpeg', got %q", ct)
}
// Data URI
dataURI := "data:image/webp;base64," + encoded
data, ct, err = parseImageData(dataURI, "image/png")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if string(data) != "hello world" {
t.Errorf("expected 'hello world', got %q", string(data))
}
if ct != "image/webp" {
t.Errorf("expected 'image/webp', got %q", ct)
}
}
func TestImageCategoryConstants(t *testing.T) {
if ImageCategoryGeneral != "general" {
t.Errorf("expected general, got %s", ImageCategoryGeneral)
}
if ImageCategoryAvatar != "avatar" {
t.Errorf("expected avatar, got %s", ImageCategoryAvatar)
}
if ImageCategoryPromo != "promo" {
t.Errorf("expected promo, got %s", ImageCategoryPromo)
}
if ImageCategoryBanner != "banner" {
t.Errorf("expected banner, got %s", ImageCategoryBanner)
}
}

View File

@@ -0,0 +1,5 @@
CREATE TABLE images (
uuid UUID PRIMARY KEY,
description TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);