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:
@@ -1,837 +0,0 @@
|
||||
## Go style guide
|
||||
|
||||
MUST write valid Go v1.22+ code, best practices.
|
||||
|
||||
## Generated folders — do not edit
|
||||
|
||||
`encore.gen/` + `.encore/` CLI-regenerated; never edit.
|
||||
|
||||
`encore.app` at repo root = app manifest — CUE-formatted **text** file, not binary, despite `.app` extension. Read like any source file.
|
||||
|
||||
## Encore local MCP server
|
||||
|
||||
Local Encore MCP wired via `.mcp.json` (registered **`encore-local`**, tool prefix `mcp__encore-local__`). Prefer for live app introspection over `Read`/grep; use docs tools when unsure of Encore API surface.
|
||||
|
||||
### Tools
|
||||
|
||||
20 tools, all prefixed `mcp__encore-local__`. MCP client gets full catalog (names, descriptions, input schemas) via `tools/list` on session init — no need enumerate here.
|
||||
|
||||
### Important tools (runtime-only — no filesystem equivalent)
|
||||
|
||||
Do what `Glob`/`Grep`/`Read` can't. For static config (services, endpoints, topics, schemas), see "When NOT to use it" below.
|
||||
|
||||
- **`call_endpoint`** — args: `service`, `endpoint`, `method`, `path`, `payload` (JSON string with body/query/headers/path-params), optional `auth_token` / `auth_payload` / `correlation_id`. Optional `retry_until: { predicate, timeout_ms?, interval_ms?, fail_on_timeout? }` polls **server-side** until predicate matches — predicate = `status: 200` OR `body_path: { path: ".events.0.orderID", equals: 7 }` OR `body_jq: ".events | length > 0"` (minimal subset: `<path> | length <op> N` or `<path>` truthy). Use instead of agent-side polling loops for eventually-consistent reads. **auto-starts app** if not running. Use instead of `curl` to test endpoints.
|
||||
- **`wait_for_subscription_message`** — args: `topic`, optional `subscription`, `timeout_ms` (default 10000), `since` (ISO/RFC3339), `match` (top-level key/value JSON filter, e.g. `{"CustomerID":"cust_42"}`). Blocks until the next message on the topic is fully processed by a subscription handler (returns or errors), then returns `{outcome, payload, duration_ms, handler_error, trace_id}`. Use to bridge async Pub/Sub work into a synchronous verify step — beats polling read-side endpoints + introspecting via `get_pubsub`/`get_traces`.
|
||||
- **`query_database`** — args: `queries` (array of `{database, query}`). Runs SQL against named DBs, multiple queries one call. Beats `encore db conn-uri` + `psql` round-trips.
|
||||
- **`get_traces`** — args: optional `service`, `endpoint`, `error` (`"true"`/`"false"`), `start_time` / `end_time` (ISO), `limit`. Returns recent request traces (timing, status, ids).
|
||||
- **`get_trace_spans`** — args: `trace_ids` (array IDs from `get_traces`). Returns full per-span details for deep debugging.
|
||||
- **`get_objects`** — args: `buckets` (array of bucket names). Lists objects + metadata in storage buckets.
|
||||
- **`search_docs`** — args: `query`, optional `hits_per_page`, `page`, `facet_filters`. Algolia-backed Encore docs search.
|
||||
- **`get_docs`** — args: `paths` (array of doc paths, e.g. `/docs/go/primitives/databases`). Fetches full doc pages found via `search_docs`.
|
||||
|
||||
### When to reach for it
|
||||
|
||||
| Situation | Tool |
|
||||
|---|---|
|
||||
| "What services / endpoints / databases exist?" | `get_metadata` once |
|
||||
| "Test my new POST /orders endpoint" | `call_endpoint` (auto-starts app) |
|
||||
| "Did the Pub/Sub handler run after I published?" | `wait_for_subscription_message` |
|
||||
| "Endpoint output is eventually consistent (e.g. read-after-publish)" | `call_endpoint` with `retry_until` |
|
||||
| "Why did last request fail?" | `get_traces` then `get_trace_spans` |
|
||||
| "How does Encore API surface work?" | `search_docs` then `get_docs` |
|
||||
|
||||
- **MCP only for runtime data** (`call_endpoint`, `query_database`, `get_traces`, `get_objects`, `search_docs`/`get_docs`). For **static structure** declared in source (services, endpoints, topics, subscriptions, schemas, secrets, middleware, cron jobs), `Glob`+`Grep`+`Read` faster + more reliable than `get_*` tool.
|
||||
|
||||
### Cloud MCP (deployed environments)
|
||||
|
||||
If deployed Encore Cloud, also register `encore-cloud` via `claude mcp add --transport http encore-cloud https://api.encore.cloud/mcp` for prod traces / deploy state — `encore-local` only sees local app.
|
||||
|
||||
## Encore check (verify app builds + endpoints work)
|
||||
|
||||
`encore check` compiles, boots, health-checks app, optionally runs `curl` once healthy. Use instead of manual `encore run + healthz poll + curl`.
|
||||
|
||||
```bash
|
||||
encore check # compile + boot only
|
||||
encore check 'curl /ping' # GET a relative path
|
||||
encore check 'curl /orders -X POST -d "{\"customer_id\":\"c1\",\"amount_cents\":1999}"' # POST with JSON body (flags after path)
|
||||
encore check 'curl /a; curl /b' # chain in one quoted string
|
||||
encore check 'curl /a' 'curl /b' # or as separate quoted args
|
||||
encore check < commands.txt # one curl per line, piped from file
|
||||
```
|
||||
|
||||
Rules embedded curl DSL:
|
||||
- **Paths relative** (start with `/`). Use `curl /orders/1`, **not** `curl http://localhost:4000/orders/1` — host/port supplied by `encore check`. Absolute URL fails with `curl path "http..." must be relative`.
|
||||
- **Path first, flags after.** `curl /orders -X POST -d '...'` works; `curl -X POST /orders ...` fails — parser reads first token after `curl` as path, rejects flags there (`curl path "-X" must be relative`).
|
||||
- One curl per quoted command. Chain `;` inside one quoted string, pass multiple quoted args, or pipe file one curl per line.
|
||||
|
||||
## Pub/sub verification
|
||||
|
||||
- Delivery async + at-least-once + not order-preserving.
|
||||
- For "most-recent first" by publish order, DO NOT sort by `created_at = NOW()` or subscription-side row id — both subscription-insert order, at-least-once can flip. Sort by column monotonic with publish (entity id from event payload, or publish-time sequence in event).
|
||||
- Handler-only checks, skip infra: `et.Topic(T).PublishedMessages()`.
|
||||
|
||||
## Encore Go domain knowledge
|
||||
|
||||
### Application structure
|
||||
|
||||
Encore uses monorepo design — one app = entire backend. Enables distributed tracing + Encore Flow via unified application model. Supports monolith + microservices with monolith-style DX.
|
||||
|
||||
Directory structure:
|
||||
/app-name
|
||||
encore.app
|
||||
service1/
|
||||
migrations/
|
||||
1_create_table.up.sql
|
||||
service1.go
|
||||
service1_test.go
|
||||
service2/
|
||||
service2.go
|
||||
|
||||
Sub-packages internal to services, cannot define APIs, used for helpers + code organization.
|
||||
|
||||
Large apps — group related services into system directories (logical groupings, no special runtime behavior):
|
||||
/app-name
|
||||
encore.app
|
||||
system1/
|
||||
service1/
|
||||
service2/
|
||||
system2/
|
||||
service3/
|
||||
|
||||
### API definition
|
||||
|
||||
Create type-safe APIs from regular Go functions via //encore:api annotation.
|
||||
|
||||
Access controls:
|
||||
- public: Accessible to anyone on internet
|
||||
- private: Only accessible within app + via cron jobs
|
||||
- auth: Public but requires valid auth
|
||||
|
||||
Function signatures:
|
||||
func Foo(ctx context.Context, p *Params) (*Response, error) // full
|
||||
func Foo(ctx context.Context) (*Response, error) // response only
|
||||
func Foo(ctx context.Context, p *Params) error // request only
|
||||
func Foo(ctx context.Context) error // minimal
|
||||
|
||||
Request/response data locations:
|
||||
- header: Use `header` tag for HTTP headers
|
||||
- query: Default GET/HEAD/DELETE, uses snake_case, supports basic types/slices
|
||||
- body: Default other methods, uses `json` tag, supports complex types
|
||||
|
||||
Path parameters: Use :name for variables, *name for wildcards. Place at end of path.
|
||||
|
||||
Sensitive data:
|
||||
- Field level: `encore:"sensitive"` tag, auto-redacted in tracing
|
||||
- Endpoint level: Add `sensitive` to //encore:api annotation
|
||||
|
||||
Type support by location:
|
||||
- headers/path: bool, numeric, string, time.Time, UUID, json.RawMessage
|
||||
- query: All above plus lists
|
||||
- body: All types including structs, maps, pointers
|
||||
|
||||
### Services
|
||||
|
||||
Service defined by creating at least one API within Go package. Package name = service name.
|
||||
|
||||
//encore:service annotation enables custom init + graceful shutdown:
|
||||
|
||||
type Service struct {
|
||||
// Dependencies here
|
||||
}
|
||||
|
||||
func initService() (*Service, error) {
|
||||
// Initialization code
|
||||
}
|
||||
|
||||
//encore:api public
|
||||
func (s *Service) MyAPI(ctx context.Context) error {
|
||||
// API implementation
|
||||
}
|
||||
|
||||
Graceful shutdown via Shutdown method:
|
||||
func (s *Service) Shutdown(force context.Context)
|
||||
- Graceful phase: Several seconds for completion
|
||||
- Forced phase: When force context canceled, terminate immediately
|
||||
|
||||
### Raw endpoints
|
||||
|
||||
Lower-level HTTP access (webhooks, WebSockets):
|
||||
|
||||
//encore:api public raw
|
||||
func Webhook(w http.ResponseWriter, req *http.Request) {
|
||||
// Process raw HTTP request
|
||||
}
|
||||
|
||||
//encore:api public raw method=POST path=/webhook/:id
|
||||
func Webhook(w http.ResponseWriter, req *http.Request) {
|
||||
id := encore.CurrentRequest().PathParams.Get("id")
|
||||
}
|
||||
|
||||
### SQL databases
|
||||
|
||||
Encore treats SQL databases as logical resources with native PostgreSQL support.
|
||||
|
||||
Create database:
|
||||
var tododb = sqldb.NewDatabase("todo", sqldb.DatabaseConfig{
|
||||
Migrations: "./migrations",
|
||||
})
|
||||
|
||||
Migration naming: number_description.up.sql (e.g., 1_create_table.up.sql)
|
||||
Migrations folder structure:
|
||||
service/
|
||||
migrations/
|
||||
1_create_table.up.sql
|
||||
2_add_field.up.sql
|
||||
service.go
|
||||
|
||||
Data operations:
|
||||
// Insert
|
||||
_, err := tododb.Exec(ctx, `
|
||||
INSERT INTO todo_item (id, title, done)
|
||||
VALUES ($1, $2, $3)
|
||||
`, id, title, done)
|
||||
|
||||
// Query
|
||||
err := tododb.QueryRow(ctx, `
|
||||
SELECT id, title, done FROM todo_item LIMIT 1
|
||||
`).Scan(&item.ID, &item.Title, &item.Done)
|
||||
// Use errors.Is(err, sqldb.ErrNoRows) for no results
|
||||
|
||||
Always Scan into typed struct fields, never `interface{}` slots — compiler enforces column-to-field shape + catches drift between SELECT list + destination struct.
|
||||
|
||||
CLI commands:
|
||||
- encore db shell database-name [--env=name] - Open psql shell
|
||||
- encore db conn-uri database-name [--env=name] - Output connection string
|
||||
- encore db proxy [--env=name] - Setup local connection proxy
|
||||
|
||||
### External databases
|
||||
|
||||
Existing databases — create dedicated package with lazy connection pool:
|
||||
|
||||
package externaldb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/jackc/pgx/v4/pgxpool"
|
||||
"go4.org/syncutil"
|
||||
)
|
||||
|
||||
func Get(ctx context.Context) (*pgxpool.Pool, error) {
|
||||
err := once.Do(func() error {
|
||||
var err error
|
||||
pool, err = setup(ctx)
|
||||
return err
|
||||
})
|
||||
return pool, err
|
||||
}
|
||||
|
||||
var (
|
||||
once syncutil.Once
|
||||
pool *pgxpool.Pool
|
||||
)
|
||||
|
||||
var secrets struct {
|
||||
ExternalDBPassword string
|
||||
}
|
||||
|
||||
func setup(ctx context.Context) (*pgxpool.Pool, error) {
|
||||
connString := fmt.Sprintf("postgresql://%s:%s@hostname:port/dbname?sslmode=require",
|
||||
"user", secrets.ExternalDBPassword)
|
||||
return pgxpool.Connect(ctx, connString)
|
||||
}
|
||||
|
||||
Works with Cassandra, DynamoDB, BigTable, MongoDB, Neo4j, other services.
|
||||
|
||||
### Shared databases
|
||||
|
||||
Default: per-service databases for isolation. Share via sqldb.Named:
|
||||
|
||||
// In report service, access todo service's database:
|
||||
var todoDB = sqldb.Named("todo")
|
||||
|
||||
//encore:api method=GET path=/report/todo
|
||||
func CountCompletedTodos(ctx context.Context) (*ReportResponse, error) {
|
||||
var report ReportResponse
|
||||
err := todoDB.QueryRow(ctx,`
|
||||
SELECT COUNT(*) FROM todo_item WHERE completed = TRUE
|
||||
`).Scan(&report.Total)
|
||||
return &report, err
|
||||
}
|
||||
|
||||
### Cron jobs
|
||||
|
||||
Declarative periodic tasks. Does not run locally or in Preview Environments.
|
||||
|
||||
import "encore.dev/cron"
|
||||
|
||||
var _ = cron.NewJob("welcome-email", cron.JobConfig{
|
||||
Title: "Send welcome emails",
|
||||
Every: 2 * cron.Hour,
|
||||
Endpoint: SendWelcomeEmail,
|
||||
})
|
||||
|
||||
//encore:api private
|
||||
func SendWelcomeEmail(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
Scheduling options:
|
||||
- Every: Must divide 24 hours evenly (e.g., 10 * cron.Minute, 6 * cron.Hour)
|
||||
- Schedule: Cron expressions (e.g., "0 4 15 * *" for 4am UTC on 15th)
|
||||
|
||||
Requirements: Endpoints must be idempotent, no request parameters, signature func(context.Context) error or func(context.Context) (*T, error)
|
||||
|
||||
### Caching
|
||||
|
||||
Redis-based distributed caching.
|
||||
|
||||
import "encore.dev/storage/cache"
|
||||
|
||||
var MyCacheCluster = cache.NewCluster("my-cache-cluster", cache.ClusterConfig{
|
||||
EvictionPolicy: cache.AllKeysLRU,
|
||||
})
|
||||
|
||||
// Keyspace with type safety
|
||||
var RequestsPerUser = cache.NewIntKeyspace[auth.UID](cluster, cache.KeyspaceConfig{
|
||||
KeyPattern: "requests/:key",
|
||||
DefaultExpiry: cache.ExpireIn(10 * time.Second),
|
||||
})
|
||||
|
||||
// Structured keys
|
||||
type MyKey struct {
|
||||
UserID auth.UID
|
||||
ResourcePath string
|
||||
}
|
||||
var ResourceRequestsPerUser = cache.NewIntKeyspace[MyKey](cluster, cache.KeyspaceConfig{
|
||||
KeyPattern: "requests/:UserID/:ResourcePath",
|
||||
DefaultExpiry: cache.ExpireIn(10 * time.Second),
|
||||
})
|
||||
|
||||
Supports strings, integers, floats, structs, sets, ordered lists.
|
||||
|
||||
### Object storage
|
||||
|
||||
Cloud-agnostic API compatible with S3, GCS, S3-compatible services.
|
||||
|
||||
var ProfilePictures = objects.NewBucket("profile-pictures", objects.BucketConfig{
|
||||
Versioned: false,
|
||||
})
|
||||
|
||||
// Public bucket with CDN
|
||||
var PublicAssets = objects.NewBucket("public-assets", objects.BucketConfig{
|
||||
Public: true,
|
||||
})
|
||||
|
||||
Operations: Upload, Download, List, Remove, Attrs, Exists
|
||||
|
||||
Bucket references for permissions:
|
||||
type myPerms interface {
|
||||
objects.Downloader
|
||||
objects.Uploader
|
||||
}
|
||||
ref := objects.BucketRef[myPerms](bucket)
|
||||
|
||||
### Pub/Sub
|
||||
|
||||
Async event broadcasting with automatic infra provisioning.
|
||||
|
||||
type SignupEvent struct{ UserID int }
|
||||
|
||||
var Signups = pubsub.NewTopic[*SignupEvent]("signups", pubsub.TopicConfig{
|
||||
DeliveryGuarantee: pubsub.AtLeastOnce,
|
||||
})
|
||||
|
||||
// Publishing
|
||||
messageID, err := Signups.Publish(ctx, &SignupEvent{UserID: id})
|
||||
|
||||
// Topic reference
|
||||
signupRef := pubsub.TopicRef[pubsub.Publisher[*SignupEvent]](Signups)
|
||||
|
||||
// Subscribing
|
||||
var _ = pubsub.NewSubscription(
|
||||
user.Signups, "send-welcome-email",
|
||||
pubsub.SubscriptionConfig[*SignupEvent]{
|
||||
Handler: SendWelcomeEmail,
|
||||
},
|
||||
)
|
||||
|
||||
// Method handler with dependency injection
|
||||
var _ = pubsub.NewSubscription(
|
||||
user.Signups, "send-welcome-email",
|
||||
pubsub.SubscriptionConfig[*SignupEvent]{
|
||||
Handler: pubsub.MethodHandler((*Service).SendWelcomeEmail),
|
||||
},
|
||||
)
|
||||
|
||||
Delivery guarantees:
|
||||
- AtLeastOnce: Handlers must be idempotent
|
||||
- ExactlyOnce: Stronger guarantees (AWS: 300 msg/sec, GCP: 3000+ msg/sec)
|
||||
|
||||
Ordering: Use OrderingAttribute matching pubsub-attr tag
|
||||
|
||||
Testing:
|
||||
msgs := et.Topic(Signups).PublishedMessages()
|
||||
assert.Len(t, msgs, 1)
|
||||
|
||||
### Secrets
|
||||
|
||||
Built-in secrets manager for API keys, passwords, private keys.
|
||||
|
||||
var secrets struct {
|
||||
SSHPrivateKey string
|
||||
GitHubAPIToken string
|
||||
}
|
||||
|
||||
func callGitHub(ctx context.Context) {
|
||||
req.Header.Add("Authorization", "token " + secrets.GitHubAPIToken)
|
||||
}
|
||||
|
||||
CLI management:
|
||||
- encore secret set --type production secret-name
|
||||
- encore secret set --type development secret-name
|
||||
- encore secret set --env env-name secret-name (env-specific override)
|
||||
|
||||
Types: production (prod), development (dev), preview (pr), local
|
||||
|
||||
Local override via .secrets.local.cue:
|
||||
GitHubAPIToken: "my-local-override-token"
|
||||
|
||||
### API calls
|
||||
|
||||
Call APIs like regular functions with automatic type checking:
|
||||
|
||||
import "encore.app/hello"
|
||||
|
||||
//encore:api public
|
||||
func MyOtherAPI(ctx context.Context) error {
|
||||
resp, err := hello.Ping(ctx, &hello.PingParams{Name: "World"})
|
||||
if err == nil {
|
||||
log.Println(resp.Message) // "Hello, World!"
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
### Errors
|
||||
|
||||
Structured errors via encore.dev/beta/errs package.
|
||||
|
||||
return &errs.Error{
|
||||
Code: errs.NotFound,
|
||||
Message: "sprocket not found",
|
||||
}
|
||||
// Returns HTTP 404 {"code": "not_found", "message": "sprocket not found"}
|
||||
|
||||
Wrapping:
|
||||
errs.Wrap(err, msg, metaPairs...)
|
||||
errs.WrapCode(err, code, msg, metaPairs...)
|
||||
|
||||
Builder pattern:
|
||||
eb := errs.B().Meta("board_id", params.ID)
|
||||
return eb.Code(errs.NotFound).Msg("board not found").Err()
|
||||
|
||||
Error codes: OK(200), Canceled(499), Unknown(500), InvalidArgument(400), DeadlineExceeded(504), NotFound(404), AlreadyExists(409), PermissionDenied(403), ResourceExhausted(429), FailedPrecondition(400), Aborted(409), OutOfRange(400), Unimplemented(501), Internal(500), Unavailable(503), DataLoss(500), Unauthenticated(401)
|
||||
|
||||
Inspection: errs.Code(err), errs.Meta(err), errs.Details(err)
|
||||
|
||||
### Authentication
|
||||
|
||||
Flexible auth with different access levels.
|
||||
|
||||
import "encore.dev/beta/auth"
|
||||
|
||||
// Basic
|
||||
//encore:authhandler
|
||||
func AuthHandler(ctx context.Context, token string) (auth.UID, error) {
|
||||
// Validate token and return user ID
|
||||
}
|
||||
|
||||
// With user data
|
||||
type Data struct {
|
||||
Username string
|
||||
}
|
||||
|
||||
//encore:authhandler
|
||||
func AuthHandler(ctx context.Context, token string) (auth.UID, *Data, error) {
|
||||
// Return user ID and custom data
|
||||
}
|
||||
|
||||
// Structured auth params
|
||||
type MyAuthParams struct {
|
||||
SessionCookie *http.Cookie `cookie:"session"`
|
||||
ClientID string `query:"client_id"`
|
||||
Authorization string `header:"Authorization"`
|
||||
}
|
||||
|
||||
//encore:authhandler
|
||||
func AuthHandler(ctx context.Context, p *MyAuthParams) (auth.UID, error) {
|
||||
// Process structured auth params
|
||||
}
|
||||
|
||||
Usage: auth.Data(), auth.UserID()
|
||||
Override for testing: auth.WithContext(ctx, auth.UID("my-user-id"), &MyAuthData{})
|
||||
|
||||
Error handling:
|
||||
return "", &errs.Error{
|
||||
Code: errs.Unauthenticated,
|
||||
Message: "invalid token",
|
||||
}
|
||||
|
||||
### Configuration
|
||||
|
||||
Env-specific config via CUE files.
|
||||
|
||||
package mysvc
|
||||
|
||||
import "encore.dev/config"
|
||||
|
||||
type SomeConfigType struct {
|
||||
ReadOnly config.Bool
|
||||
Example config.String
|
||||
}
|
||||
|
||||
var cfg *SomeConfigType = config.Load[*SomeConfigType]()
|
||||
|
||||
CUE tags for constraints:
|
||||
type FooBar {
|
||||
A int `cue:">100"`
|
||||
B int `cue:"A-50"`
|
||||
C int `cue:"A+B"`
|
||||
}
|
||||
|
||||
Config types: config.String, config.Bool, config.Int, config.Float64, config.Time, config.UUID, config.Value[T], config.Values[T]
|
||||
|
||||
Meta values:
|
||||
- APIBaseURL, Environment.Name, Environment.Type (production/development/ephemeral/test), Environment.Cloud (aws/gcp/encore/local)
|
||||
|
||||
Testing: et.SetCfg(cfg.SendEmails, true)
|
||||
|
||||
CUE patterns:
|
||||
- Defaults: value: type | *default_value
|
||||
- Switch: array with conditionals, take [0]
|
||||
|
||||
### CORS
|
||||
|
||||
Configure in encore.app file:
|
||||
- debug: Enable CORS debug logging
|
||||
- allow_headers: Additional accepted headers ("*" allows all)
|
||||
- expose_headers: Additional exposed headers
|
||||
- allow_origins_without_credentials: Defaults to ["*"]
|
||||
- allow_origins_with_credentials: For authenticated requests, supports wildcards like "https://*.example.com"
|
||||
|
||||
### Metadata
|
||||
|
||||
Access app + request info via encore.dev package.
|
||||
|
||||
// Application metadata
|
||||
meta := encore.Meta()
|
||||
// meta.AppID, meta.APIBaseURL, meta.Environment, meta.Build, meta.Deploy
|
||||
|
||||
// Request metadata
|
||||
req := encore.CurrentRequest()
|
||||
// req.Service, req.Endpoint, req.Path, req.StartTime
|
||||
|
||||
// Cloud-specific behavior
|
||||
switch encore.Meta().Environment.Cloud {
|
||||
case encore.CloudAWS:
|
||||
return writeIntoRedshift(ctx, action, user)
|
||||
case encore.CloudGCP:
|
||||
return writeIntoBigQuery(ctx, action, user)
|
||||
}
|
||||
|
||||
### Middleware
|
||||
|
||||
Reusable code running before/after API requests.
|
||||
|
||||
//encore:middleware global target=all
|
||||
func ValidationMiddleware(req middleware.Request, next middleware.Next) middleware.Response {
|
||||
payload := req.Data().Payload
|
||||
if validator, ok := payload.(interface { Validate() error }); ok {
|
||||
if err := validator.Validate(); err != nil {
|
||||
err = errs.WrapCode(err, errs.InvalidArgument, "validation failed")
|
||||
return middleware.Response{Err: err}
|
||||
}
|
||||
}
|
||||
return next(req)
|
||||
}
|
||||
|
||||
// With dependency injection
|
||||
//encore:middleware target=all
|
||||
func (s *Service) MyMiddleware(req middleware.Request, next middleware.Next) middleware.Response {
|
||||
// Implementation
|
||||
}
|
||||
|
||||
// Tag-based targeting
|
||||
//encore:middleware target=tag:cache
|
||||
func CachingMiddleware(req middleware.Request, next middleware.Next) middleware.Response {
|
||||
// ...
|
||||
}
|
||||
|
||||
//encore:api public method=GET path=/user/:id tag:cache
|
||||
func GetUser(ctx context.Context, id string) (*User, error) {
|
||||
// Implementation
|
||||
}
|
||||
|
||||
Ordering: Global before service-specific, lexicographic by filename.
|
||||
|
||||
### Mocking
|
||||
|
||||
Built-in mocking for isolated testing.
|
||||
|
||||
// Mock endpoint for single test
|
||||
func Test_Something(t *testing.T) {
|
||||
t.Parallel()
|
||||
et.MockEndpoint(products.GetPrice, func(ctx context.Context, p *products.PriceParams) (*products.PriceResponse, error) {
|
||||
return &products.PriceResponse{Price: 100}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// Mock endpoint for all tests in package
|
||||
func TestMain(m *testing.M) {
|
||||
et.MockEndpoint(products.GetPrice, func(ctx context.Context, p *products.PriceParams) (*products.PriceResponse, error) {
|
||||
return &products.PriceResponse{Price: 100}, nil
|
||||
})
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// Mock entire service
|
||||
et.MockService("products", &products.Service{
|
||||
SomeField: "a testing value",
|
||||
})
|
||||
|
||||
// Type-safe service mocking
|
||||
et.MockService[products.Interface]("products", &myMockObject{})
|
||||
|
||||
### Testing
|
||||
|
||||
Run tests: encore test ./...
|
||||
Supports all standard go test flags. Built-in tracing at localhost:9400.
|
||||
|
||||
Database testing:
|
||||
- Automatic setup in separate cluster, optimized for speed
|
||||
- Temporary databases: et.NewTestDatabase() creates isolated, fully migrated DB
|
||||
|
||||
Service structs: Lazy init, instance sharing between tests
|
||||
- Isolate: et.EnableServiceInstanceIsolation()
|
||||
|
||||
### Validation
|
||||
|
||||
Automatic request validation via Validate() method.
|
||||
|
||||
type MyRequest struct {
|
||||
Email string
|
||||
}
|
||||
|
||||
func (r *MyRequest) Validate() error {
|
||||
if !isValidEmail(r.Email) {
|
||||
return &errs.Error{Code: errs.InvalidArgument, Message: "invalid email"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Validation runs after deserialization, before handler. Non-errs.Error errors become InvalidArgument (HTTP 400).
|
||||
|
||||
### CGO
|
||||
|
||||
Enable in encore.app:
|
||||
{
|
||||
"id": "my-app-id",
|
||||
"build": {
|
||||
"cgo_enabled": true
|
||||
}
|
||||
}
|
||||
Uses Ubuntu builder with gcc. Libraries must support static linking.
|
||||
|
||||
### Clerk auth
|
||||
|
||||
Implement Clerk auth:
|
||||
|
||||
package auth
|
||||
|
||||
import "github.com/clerkinc/clerk-sdk-go/clerk"
|
||||
|
||||
type Service struct {
|
||||
client clerk.Client
|
||||
}
|
||||
|
||||
func initService() (*Service, error) {
|
||||
client, err := clerk.NewClient(secrets.ClientSecretKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Service{client: client}, nil
|
||||
}
|
||||
|
||||
type UserData struct {
|
||||
ID string
|
||||
Username *string
|
||||
FirstName *string
|
||||
LastName *string
|
||||
ProfileImageURL string
|
||||
PrimaryEmailAddressID *string
|
||||
EmailAddresses []clerk.EmailAddress
|
||||
}
|
||||
|
||||
//encore:authhandler
|
||||
func (s *Service) AuthHandler(ctx context.Context, token string) (auth.UID, *UserData, error) {
|
||||
// Token verification and user data retrieval
|
||||
}
|
||||
|
||||
Set secrets:
|
||||
- encore secret set --prod ClientSecretKey
|
||||
- encore secret set --dev ClientSecretKey
|
||||
|
||||
### Dependency injection
|
||||
|
||||
Add dependencies as struct fields for easy testing:
|
||||
|
||||
package email
|
||||
|
||||
//encore:service
|
||||
type Service struct {
|
||||
sendgridClient *sendgrid.Client
|
||||
}
|
||||
|
||||
func initService() (*Service, error) {
|
||||
client, err := sendgrid.NewClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Service{sendgridClient: client}, nil
|
||||
}
|
||||
|
||||
//encore:api private
|
||||
func (s *Service) Send(ctx context.Context, p *SendParams) error {
|
||||
// Use s.sendgridClient
|
||||
}
|
||||
|
||||
// For testing, use interface
|
||||
type sendgridClient interface {
|
||||
SendEmail(...)
|
||||
}
|
||||
|
||||
func TestFoo(t *testing.T) {
|
||||
svc := &Service{sendgridClient: &myMockClient{}}
|
||||
// Test
|
||||
}
|
||||
|
||||
### Pub/Sub outbox
|
||||
|
||||
Transactional outbox pattern for database + Pub/Sub consistency.
|
||||
|
||||
var SignupsTopic = pubsub.NewTopic[*SignupEvent](/* ... */)
|
||||
ref := pubsub.TopicRef[pubsub.Publisher[*SignupEvent]](SignupsTopic)
|
||||
ref = outbox.Bind(ref, outbox.TxPersister(tx))
|
||||
|
||||
Required schema:
|
||||
CREATE TABLE outbox (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
topic TEXT NOT NULL,
|
||||
data JSONB NOT NULL,
|
||||
inserted_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE INDEX outbox_topic_idx ON outbox (topic, id);
|
||||
|
||||
Relay setup:
|
||||
type Service struct {
|
||||
signupsRef pubsub.Publisher[*SignupEvent]
|
||||
}
|
||||
|
||||
func initService() (*Service, error) {
|
||||
relay := outbox.NewRelay(outbox.SQLDBStore(db))
|
||||
signupsRef := pubsub.TopicRef[pubsub.Publisher[*SignupEvent]](SignupsTopic)
|
||||
outbox.RegisterTopic(relay, signupsRef)
|
||||
go relay.PollForMessage(context.Background(), -1)
|
||||
return &Service{signupsRef: signupsRef}, nil
|
||||
}
|
||||
|
||||
Supports: encore.dev/storage/sqldb, database/sql, github.com/jackc/pgx/v5
|
||||
|
||||
### Encore Toolbar (frontend dev panel)
|
||||
|
||||
Dev-only browser panel: intercepts frontend HTTP calls, captures Encore trace IDs, jumps to traces in the dashboard. Install: add `<script src="https://encore.dev/encore-toolbar.js"></script>` early in `<head>` (no `async`/`defer`). Reach for it when a frontend talks to an Encore Go backend.
|
||||
|
||||
### Example apps
|
||||
|
||||
- Hello World: https://github.com/encoredev/examples/tree/main/hello-world
|
||||
- URL Shortener: https://github.com/encoredev/examples/tree/main/url-shortener
|
||||
- Uptime Monitor: https://github.com/encoredev/examples/tree/main/uptime
|
||||
|
||||
## Encore CLI reference
|
||||
|
||||
Execution:
|
||||
- encore help [command] - Lists available commands (or shows usage for specific command). Reach for it when unsure which subcommand or flag to use.
|
||||
- encore run [--debug] [--watch=true] [-p port] [flags] - Runs app. Before starting yourself, check if already running by polling `http://localhost:<port>/__encore/healthz` (default port `4000`) — `200` = healthy, hit endpoints directly without re-running.
|
||||
- encore check ['curl ...'; 'curl ...'] | encore check < commands.txt - Compile + boot app, verify every service healthy, optionally run curl commands. See dedicated section above.
|
||||
- encore test ./... [go test flags] - Wraps `go test ./...` with Encore's infra provisioning — boots isolated test databases, Pub/Sub, etc. before handing off to `go test`. Accepts all `go test` flags, plus `--prepare`, `--codegen-debug`, `--trace`.
|
||||
- encore exec path/to/script [args...] - Run an executable script against the local Encore app
|
||||
|
||||
App management:
|
||||
- encore app clone [app-id] [directory] - Clone app
|
||||
- encore app create [name] [--example=name] [-l go] - Create new app
|
||||
- encore app init [name] - Create from existing repo
|
||||
- encore app link [app-id] [-f] - Link app with server
|
||||
|
||||
Authentication:
|
||||
- encore auth login/logout/signup/whoami
|
||||
|
||||
Daemon:
|
||||
- encore daemon - Restart daemon
|
||||
- encore daemon env - Output environment info
|
||||
|
||||
Database:
|
||||
- encore db shell database-name [--env=name] - psql shell (--write, --admin, --superuser)
|
||||
- encore db conn-uri database-name [--env=name] - Connection string
|
||||
- encore db proxy [--env=name] - Local proxy
|
||||
- encore db reset <db-names...|--all> - Reset databases
|
||||
|
||||
Code generation:
|
||||
- encore gen client [app-id] [--env=name] [--lang=lang] - Generate API client
|
||||
Languages: go, typescript, javascript, openapi
|
||||
|
||||
Logging:
|
||||
- encore logs [--env=prod] [--json] [-q] - Stream logs
|
||||
|
||||
Kubernetes:
|
||||
- encore k8s configure --env=ENV_NAME - Update kubectl config
|
||||
|
||||
Secrets:
|
||||
- encore secret set --type TYPE <secret-name> (types: production, development, preview, local)
|
||||
- encore secret set --env env-name <secret-name>
|
||||
- encore secret list [keys...]
|
||||
- encore secret delete <id>
|
||||
|
||||
Namespaces (infrastructure environments, alias `encore ns`):
|
||||
- encore namespace list [--output=columns|json] - List infra namespaces
|
||||
- encore namespace create NAME - Create a namespace
|
||||
- encore namespace switch [--create] NAME - Switch active namespace
|
||||
- encore namespace delete NAME - Delete a namespace
|
||||
|
||||
MCP (programmatic access to the local app — same surface as `.mcp.json`):
|
||||
- encore mcp run - stdio-based MCP session
|
||||
- encore mcp start - SSE-based MCP session; prints the SSE URL
|
||||
|
||||
Config:
|
||||
- encore config <key> [<value>] [--app|--global|--all] - Get/set CLI configuration
|
||||
|
||||
Telemetry:
|
||||
- encore telemetry - Status
|
||||
- encore telemetry enable / disable - Toggle telemetry reporting
|
||||
|
||||
LLM rules:
|
||||
- encore llm-rules init - Generate LLM rule files for this project
|
||||
|
||||
Random data (utility):
|
||||
- encore rand uuid [-1|-4|-6|-7] - Generate UUID (default v4)
|
||||
- encore rand bytes N [-f format] - Generate N random bytes
|
||||
- encore rand words [--sep=SEP] NUM - Generate memorable passphrase
|
||||
|
||||
Deploy (alpha):
|
||||
- encore alpha deploy --env=<name> (--commit=<sha> | --branch=<name>) - Deploy app to a cloud env
|
||||
|
||||
Version:
|
||||
- encore version - Report version
|
||||
- encore version update - Check and apply updates
|
||||
|
||||
Build:
|
||||
- encore build docker IMAGE_TAG [--base string] [--push] [--cgo] [--os] [--arch] - Build Docker image
|
||||
@@ -1,12 +1,7 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"encore-local": {
|
||||
"args": [
|
||||
"mcp",
|
||||
"run",
|
||||
"--app=q55oi"
|
||||
],
|
||||
"command": "encore"
|
||||
"url": "http://127.0.0.1:9900/sse?appID=q55oi"
|
||||
}
|
||||
}
|
||||
}
|
||||
154
advertising/advertising.go
Normal file
154
advertising/advertising.go
Normal file
@@ -0,0 +1,154 @@
|
||||
// Service advertising gestisce l'elenco delle inserzioni pubblicitarie (Advertising Directory).
|
||||
package advertising
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"encore.dev/beta/errs"
|
||||
"encore.dev/storage/sqldb"
|
||||
"encore.dev/types/uuid"
|
||||
)
|
||||
|
||||
var db = sqldb.NewDatabase("advertising", sqldb.DatabaseConfig{
|
||||
Migrations: "./migrations",
|
||||
})
|
||||
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusActive Status = "active"
|
||||
StatusInactive Status = "inactive"
|
||||
)
|
||||
|
||||
type Advertising struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Nome string `json:"nome"`
|
||||
Email string `json:"email"`
|
||||
Status Status `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type CreateAdvertisingParams struct {
|
||||
Nome string `json:"nome"`
|
||||
Email string `json:"email"`
|
||||
Status Status `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
func (p *CreateAdvertisingParams) Validate() error {
|
||||
if p == nil {
|
||||
return &errs.Error{Code: errs.InvalidArgument, Message: "i parametri di creazione sono obbligatori"}
|
||||
}
|
||||
|
||||
email, err := normalizeEmailValue(p.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.Email = email
|
||||
|
||||
nome := normalizeText(p.Nome)
|
||||
if nome == "" {
|
||||
return &errs.Error{Code: errs.InvalidArgument, Message: "il nome è obbligatorio"}
|
||||
}
|
||||
p.Nome = nome
|
||||
|
||||
if p.Status == "" {
|
||||
p.Status = StatusActive
|
||||
} else if p.Status != StatusActive && p.Status != StatusInactive {
|
||||
return &errs.Error{Code: errs.InvalidArgument, Message: "lo status deve essere 'active' o 'inactive'"}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeEmailValue(email string) (string, error) {
|
||||
email = strings.TrimSpace(strings.ToLower(email))
|
||||
if email == "" {
|
||||
return "", &errs.Error{Code: errs.InvalidArgument, Message: "l'email è obbligatoria"}
|
||||
}
|
||||
parsed, err := mail.ParseAddress(email)
|
||||
if err != nil || !strings.Contains(parsed.Address, "@") || !strings.Contains(parsed.Address, ".") {
|
||||
return "", &errs.Error{Code: errs.InvalidArgument, Message: "indirizzo email non valido"}
|
||||
}
|
||||
return strings.TrimSpace(strings.ToLower(parsed.Address)), nil
|
||||
}
|
||||
|
||||
func normalizeText(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Fields(value)
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
type ListAdvertisingResponse struct {
|
||||
Advertising []*Advertising `json:"advertising"`
|
||||
}
|
||||
|
||||
// List ritorna l'intera lista delle pubblicità ordinate per data di creazione.
|
||||
//
|
||||
//encore:api public method=GET path=/advertising
|
||||
func List(ctx context.Context) (*ListAdvertisingResponse, error) {
|
||||
rows, err := db.Query(ctx, `
|
||||
SELECT id, nome, email, status, created_at
|
||||
FROM advertising
|
||||
ORDER BY created_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "errore durante il recupero delle pubblicità")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var list []*Advertising
|
||||
for rows.Next() {
|
||||
var a Advertising
|
||||
if err := rows.Scan(&a.ID, &a.Nome, &a.Email, &a.Status, &a.CreatedAt); err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "errore durante la lettura del record pubblicità")
|
||||
}
|
||||
list = append(list, &a)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "errore durante l'iterazione delle pubblicità")
|
||||
}
|
||||
|
||||
if list == nil {
|
||||
list = []*Advertising{}
|
||||
}
|
||||
|
||||
return &ListAdvertisingResponse{Advertising: list}, nil
|
||||
}
|
||||
|
||||
// Create crea un nuovo cliente/pubblicità con validazione dell'email e dei campi.
|
||||
//
|
||||
//encore:api public method=POST path=/advertising
|
||||
func Create(ctx context.Context, p *CreateAdvertisingParams) (*Advertising, error) {
|
||||
if err := p.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
id, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "impossibile generare l'identificativo UUID")
|
||||
}
|
||||
|
||||
item := Advertising{
|
||||
ID: id,
|
||||
Nome: p.Nome,
|
||||
Email: p.Email,
|
||||
Status: p.Status,
|
||||
}
|
||||
|
||||
err = db.QueryRow(ctx, `
|
||||
INSERT INTO advertising (id, nome, email, status)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING created_at
|
||||
`, id, p.Nome, p.Email, p.Status).Scan(&item.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "impossibile creare la pubblicità")
|
||||
}
|
||||
|
||||
return &item, nil
|
||||
}
|
||||
92
advertising/advertising_test.go
Normal file
92
advertising/advertising_test.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package advertising
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCreateAdvertisingParamsValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
params *CreateAdvertisingParams
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "nil params",
|
||||
params: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty email",
|
||||
params: &CreateAdvertisingParams{
|
||||
Nome: "Azienda Rossi",
|
||||
Email: "",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid email format",
|
||||
params: &CreateAdvertisingParams{
|
||||
Nome: "Azienda Rossi",
|
||||
Email: "invalid-email-address",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "email missing domain dot",
|
||||
params: &CreateAdvertisingParams{
|
||||
Nome: "Azienda Rossi",
|
||||
Email: "test@domain",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty name",
|
||||
params: &CreateAdvertisingParams{
|
||||
Nome: " ",
|
||||
Email: "azienda@rossi.it",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid status",
|
||||
params: &CreateAdvertisingParams{
|
||||
Nome: "Azienda Rossi",
|
||||
Email: "azienda@rossi.it",
|
||||
Status: "pending",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "valid with default active status",
|
||||
params: &CreateAdvertisingParams{
|
||||
Nome: " Azienda Rossi ",
|
||||
Email: " AZIENDA@rossi.it ",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid with explicit inactive status",
|
||||
params: &CreateAdvertisingParams{
|
||||
Nome: "Azienda Bianchi",
|
||||
Email: "info@bianchi.com",
|
||||
Status: StatusInactive,
|
||||
},
|
||||
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.Status != StatusActive && tt.params.Status != StatusInactive {
|
||||
t.Errorf("expected Status to be active or inactive, got %s", tt.params.Status)
|
||||
}
|
||||
if tt.params.Email != "azienda@rossi.it" && tt.params.Email != "info@bianchi.com" {
|
||||
t.Errorf("expected email to be normalized, got %s", tt.params.Email)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
7
advertising/migrations/1_create_advertising.up.sql
Normal file
7
advertising/migrations/1_create_advertising.up.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE advertising (
|
||||
id UUID PRIMARY KEY,
|
||||
nome TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('active', 'inactive')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
7
advertising/migrations/2_create_promo.up.sql
Normal file
7
advertising/migrations/2_create_promo.up.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE promo (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
position INT NOT NULL CHECK (position IN (0, 1, 2)),
|
||||
item JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
active_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
1
advertising/migrations/3_add_section_to_promo.up.sql
Normal file
1
advertising/migrations/3_add_section_to_promo.up.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE promo ADD COLUMN section VARCHAR(50) NOT NULL DEFAULT '';
|
||||
2
advertising/migrations/4_add_weeks_to_promo.up.sql
Normal file
2
advertising/migrations/4_add_weeks_to_promo.up.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE promo ADD COLUMN year INT NOT NULL DEFAULT EXTRACT(YEAR FROM NOW());
|
||||
ALTER TABLE promo ADD COLUMN weeks JSONB NOT NULL DEFAULT '[]'::jsonb;
|
||||
348
advertising/promo.go
Normal file
348
advertising/promo.go
Normal file
@@ -0,0 +1,348 @@
|
||||
package advertising
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"encore.dev/beta/errs"
|
||||
"encore.dev/storage/sqldb"
|
||||
)
|
||||
|
||||
type PromoPosition int
|
||||
|
||||
const (
|
||||
PromoLeft PromoPosition = 0
|
||||
PromoRight1 PromoPosition = 1
|
||||
PromoRight2 PromoPosition = 2
|
||||
)
|
||||
|
||||
type PromoLeftSections string
|
||||
|
||||
const (
|
||||
PromoLeftWeek PromoLeftSections = "week"
|
||||
PromoLeftRaccomended PromoLeftSections = "raccomended"
|
||||
PromoLeftDiscover PromoLeftSections = "discover"
|
||||
)
|
||||
|
||||
type PromoImage struct {
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Proportion string `json:"proportion"`
|
||||
}
|
||||
|
||||
var PromoLeftImageSize = PromoImage{
|
||||
Width: 230,
|
||||
Height: 0,
|
||||
Proportion: "2:3",
|
||||
}
|
||||
|
||||
var PromoRight1ImageSize = PromoImage{
|
||||
Width: 230,
|
||||
Height: 0,
|
||||
Proportion: "2:3",
|
||||
}
|
||||
|
||||
var PromoRight2ImageSize = PromoImage{
|
||||
Width: 230,
|
||||
Height: 345,
|
||||
Proportion: "",
|
||||
}
|
||||
|
||||
type PromoRight1Sections string
|
||||
|
||||
const (
|
||||
PromoRight1Show PromoRight1Sections = "show"
|
||||
PromoRight1Service PromoRight1Sections = "service"
|
||||
)
|
||||
|
||||
type PromoRight2Sections string
|
||||
|
||||
const (
|
||||
PromoRight2Top PromoRight2Sections = "top"
|
||||
PromoRight2Selected PromoRight2Sections = "selected"
|
||||
)
|
||||
|
||||
type PromoContent struct {
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
MobileOnly bool `json:"mobile_only"`
|
||||
MobileImageURL string `json:"mobile_image_url"`
|
||||
NewWindow bool `json:"new_window"`
|
||||
ImageURL string `json:"image_url"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type PromoItem struct {
|
||||
Title string `json:"title"`
|
||||
Content []PromoContent `json:"content"`
|
||||
}
|
||||
|
||||
type Promo struct {
|
||||
ID int64 `json:"id"`
|
||||
Position PromoPosition `json:"position"`
|
||||
Section string `json:"section"`
|
||||
Item []PromoItem `json:"item"`
|
||||
Year int `json:"year"`
|
||||
Weeks []int `json:"weeks"`
|
||||
ActiveFrom time.Time `json:"active_from"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type CreatePromoParams struct {
|
||||
Position PromoPosition `json:"position"`
|
||||
Section string `json:"section"`
|
||||
Item []PromoItem `json:"item"`
|
||||
Year int `json:"year,omitempty"`
|
||||
Weeks []int `json:"weeks,omitempty"`
|
||||
ActiveFrom *time.Time `json:"active_from,omitempty"`
|
||||
}
|
||||
|
||||
func validatePromoFields(position PromoPosition, section string) error {
|
||||
if position < PromoLeft || position > PromoRight2 {
|
||||
return &errs.Error{Code: errs.InvalidArgument, Message: "posizione promo non valida, ammessi solo 0 (PromoLeft), 1 (PromoRight1), 2 (PromoRight2)"}
|
||||
}
|
||||
switch position {
|
||||
case PromoLeft:
|
||||
if section != string(PromoLeftWeek) && section != string(PromoLeftRaccomended) && section != string(PromoLeftDiscover) {
|
||||
return &errs.Error{Code: errs.InvalidArgument, Message: "sezione non valida per PromoLeft, ammessi solo: 'week', 'raccomended', 'discover'"}
|
||||
}
|
||||
case PromoRight1:
|
||||
if section != string(PromoRight1Show) && section != string(PromoRight1Service) {
|
||||
return &errs.Error{Code: errs.InvalidArgument, Message: "sezione non valida per PromoRight1, ammessi solo: 'show', 'service'"}
|
||||
}
|
||||
case PromoRight2:
|
||||
if section != string(PromoRight2Top) && section != string(PromoRight2Selected) {
|
||||
return &errs.Error{Code: errs.InvalidArgument, Message: "sezione non valida per PromoRight2, ammessi solo: 'top', 'selected'"}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePromoWeeks(weeks []int) error {
|
||||
for _, w := range weeks {
|
||||
if w < 1 || w > 53 {
|
||||
return &errs.Error{Code: errs.InvalidArgument, Message: "numero settimana non valido, deve essere compreso tra 1 e 53"}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *CreatePromoParams) Validate() error {
|
||||
if p == nil {
|
||||
return &errs.Error{Code: errs.InvalidArgument, Message: "i parametri di creazione sono obbligatori"}
|
||||
}
|
||||
if err := validatePromoFields(p.Position, p.Section); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Item == nil {
|
||||
p.Item = []PromoItem{}
|
||||
}
|
||||
if p.Weeks == nil {
|
||||
p.Weeks = []int{}
|
||||
}
|
||||
if err := validatePromoWeeks(p.Weeks); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Year == 0 {
|
||||
p.Year = time.Now().Year()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type UpdatePromoParams struct {
|
||||
Position PromoPosition `json:"position"`
|
||||
Section string `json:"section"`
|
||||
Item []PromoItem `json:"item"`
|
||||
Year int `json:"year,omitempty"`
|
||||
Weeks []int `json:"weeks,omitempty"`
|
||||
ActiveFrom *time.Time `json:"active_from,omitempty"`
|
||||
}
|
||||
|
||||
func (p *UpdatePromoParams) Validate() error {
|
||||
if p == nil {
|
||||
return &errs.Error{Code: errs.InvalidArgument, Message: "i parametri di aggiornamento sono obbligatori"}
|
||||
}
|
||||
if err := validatePromoFields(p.Position, p.Section); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Item == nil {
|
||||
p.Item = []PromoItem{}
|
||||
}
|
||||
if p.Weeks == nil {
|
||||
p.Weeks = []int{}
|
||||
}
|
||||
if err := validatePromoWeeks(p.Weeks); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Year == 0 {
|
||||
p.Year = time.Now().Year()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ListPromoResponse struct {
|
||||
Promos []*Promo `json:"promos"`
|
||||
}
|
||||
|
||||
type ListPromotionsResponse struct {
|
||||
Promotions []*Promo `json:"promotions"`
|
||||
}
|
||||
|
||||
func queryPromos(ctx context.Context) ([]*Promo, error) {
|
||||
rows, err := db.Query(ctx, `
|
||||
SELECT id, position, section, item, year, weeks, active_from, created_at
|
||||
FROM promo
|
||||
ORDER BY active_from DESC, id DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "errore durante il recupero delle promozioni")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var promos []*Promo
|
||||
for rows.Next() {
|
||||
var (
|
||||
p Promo
|
||||
itemBytes []byte
|
||||
weeksBytes []byte
|
||||
)
|
||||
if err := rows.Scan(&p.ID, &p.Position, &p.Section, &itemBytes, &p.Year, &weeksBytes, &p.ActiveFrom, &p.CreatedAt); err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "errore durante la lettura del record promo")
|
||||
}
|
||||
if len(itemBytes) > 0 {
|
||||
if err := json.Unmarshal(itemBytes, &p.Item); err != nil {
|
||||
p.Item = []PromoItem{}
|
||||
}
|
||||
} else {
|
||||
p.Item = []PromoItem{}
|
||||
}
|
||||
if len(weeksBytes) > 0 {
|
||||
if err := json.Unmarshal(weeksBytes, &p.Weeks); err != nil {
|
||||
p.Weeks = []int{}
|
||||
}
|
||||
} else {
|
||||
p.Weeks = []int{}
|
||||
}
|
||||
promos = append(promos, &p)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "errore durante l'iterazione delle promozioni")
|
||||
}
|
||||
|
||||
if promos == nil {
|
||||
promos = []*Promo{}
|
||||
}
|
||||
|
||||
return promos, nil
|
||||
}
|
||||
|
||||
// ListPromos ritorna la lista completa di tutte le promozioni.
|
||||
//
|
||||
//encore:api public method=GET path=/promo
|
||||
func ListPromos(ctx context.Context) (*ListPromoResponse, error) {
|
||||
promos, err := queryPromos(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ListPromoResponse{Promos: promos}, nil
|
||||
}
|
||||
|
||||
// ListPromotions ritorna la lista completa delle promozioni arricchite con le settimane lavorative attive.
|
||||
//
|
||||
//encore:api public method=GET path=/promotions
|
||||
func ListPromotions(ctx context.Context) (*ListPromotionsResponse, error) {
|
||||
promos, err := queryPromos(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ListPromotionsResponse{Promotions: promos}, nil
|
||||
}
|
||||
|
||||
// CreatePromo inserisce una nuova promozione nel database.
|
||||
//
|
||||
//encore:api public method=POST path=/promo
|
||||
func CreatePromo(ctx context.Context, p *CreatePromoParams) (*Promo, error) {
|
||||
if err := p.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
itemJSON, err := json.Marshal(p.Item)
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.InvalidArgument, "formato item non valido")
|
||||
}
|
||||
|
||||
weeksJSON, err := json.Marshal(p.Weeks)
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.InvalidArgument, "formato settimane non valido")
|
||||
}
|
||||
|
||||
activeFrom := time.Now()
|
||||
if p.ActiveFrom != nil && !p.ActiveFrom.IsZero() {
|
||||
activeFrom = *p.ActiveFrom
|
||||
}
|
||||
|
||||
var promo Promo
|
||||
promo.Position = p.Position
|
||||
promo.Section = p.Section
|
||||
promo.Item = p.Item
|
||||
promo.Year = p.Year
|
||||
promo.Weeks = p.Weeks
|
||||
promo.ActiveFrom = activeFrom
|
||||
|
||||
err = db.QueryRow(ctx, `
|
||||
INSERT INTO promo (position, section, item, year, weeks, active_from)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, created_at
|
||||
`, p.Position, p.Section, itemJSON, p.Year, weeksJSON, activeFrom).Scan(&promo.ID, &promo.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "impossibile creare la promozione")
|
||||
}
|
||||
|
||||
return &promo, nil
|
||||
}
|
||||
|
||||
// UpdatePromo aggiorna una promozione esistente nel database.
|
||||
//
|
||||
//encore:api public method=PUT path=/promo/:id
|
||||
func UpdatePromo(ctx context.Context, id int64, p *UpdatePromoParams) (*Promo, error) {
|
||||
if err := p.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
itemJSON, err := json.Marshal(p.Item)
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.InvalidArgument, "formato item non valido")
|
||||
}
|
||||
|
||||
weeksJSON, err := json.Marshal(p.Weeks)
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.InvalidArgument, "formato settimane non valido")
|
||||
}
|
||||
|
||||
var promo Promo
|
||||
promo.ID = id
|
||||
promo.Position = p.Position
|
||||
promo.Section = p.Section
|
||||
promo.Item = p.Item
|
||||
promo.Year = p.Year
|
||||
promo.Weeks = p.Weeks
|
||||
|
||||
query := `
|
||||
UPDATE promo
|
||||
SET position = $1, section = $2, item = $3, year = $4, weeks = $5,
|
||||
active_from = COALESCE($6, active_from)
|
||||
WHERE id = $7
|
||||
RETURNING active_from, created_at
|
||||
`
|
||||
err = db.QueryRow(ctx, query, p.Position, p.Section, itemJSON, p.Year, weeksJSON, p.ActiveFrom, id).Scan(&promo.ActiveFrom, &promo.CreatedAt)
|
||||
if errors.Is(err, sqldb.ErrNoRows) {
|
||||
return nil, &errs.Error{Code: errs.NotFound, Message: "promozione non trovata"}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "impossibile aggiornare la promozione")
|
||||
}
|
||||
|
||||
return &promo, nil
|
||||
}
|
||||
214
advertising/promo_test.go
Normal file
214
advertising/promo_test.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package advertising
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCreatePromoParamsValidate(t *testing.T) {
|
||||
now := time.Now()
|
||||
tests := []struct {
|
||||
name string
|
||||
params *CreatePromoParams
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "nil params",
|
||||
params: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid negative position",
|
||||
params: &CreatePromoParams{
|
||||
Position: -1,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid position > 2",
|
||||
params: &CreatePromoParams{
|
||||
Position: 3,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "valid PromoLeft with week section",
|
||||
params: &CreatePromoParams{
|
||||
Position: PromoLeft,
|
||||
Section: string(PromoLeftWeek),
|
||||
ActiveFrom: &now,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid PromoRight1 with show section",
|
||||
params: &CreatePromoParams{
|
||||
Position: PromoRight1,
|
||||
Section: string(PromoRight1Show),
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid PromoRight2 with items and top section",
|
||||
params: &CreatePromoParams{
|
||||
Position: PromoRight2,
|
||||
Section: string(PromoRight2Top),
|
||||
Item: []PromoItem{
|
||||
{
|
||||
Title: "Banner Estivo",
|
||||
Content: []PromoContent{
|
||||
{
|
||||
Width: 300,
|
||||
Height: 250,
|
||||
ImageURL: "https://example.com/banner.jpg",
|
||||
URL: "https://example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid section for PromoLeft",
|
||||
params: &CreatePromoParams{
|
||||
Position: PromoLeft,
|
||||
Section: "invalid-section",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid section for PromoRight1",
|
||||
params: &CreatePromoParams{
|
||||
Position: PromoRight1,
|
||||
Section: string(PromoLeftWeek),
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid section for PromoRight2",
|
||||
params: &CreatePromoParams{
|
||||
Position: PromoRight2,
|
||||
Section: string(PromoRight1Show),
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid week < 1",
|
||||
params: &CreatePromoParams{
|
||||
Position: PromoLeft,
|
||||
Section: string(PromoLeftWeek),
|
||||
Weeks: []int{0},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid week > 53",
|
||||
params: &CreatePromoParams{
|
||||
Position: PromoLeft,
|
||||
Section: string(PromoLeftWeek),
|
||||
Weeks: []int{54},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "valid weeks",
|
||||
params: &CreatePromoParams{
|
||||
Position: PromoLeft,
|
||||
Section: string(PromoLeftWeek),
|
||||
Weeks: []int{1, 10, 52},
|
||||
},
|
||||
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 && tt.params.Item == nil {
|
||||
t.Errorf("expected Item slice to be initialized, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePromoParamsValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
params *UpdatePromoParams
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "nil params",
|
||||
params: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid negative position",
|
||||
params: &UpdatePromoParams{
|
||||
Position: -1,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "valid PromoLeft week",
|
||||
params: &UpdatePromoParams{
|
||||
Position: PromoLeft,
|
||||
Section: string(PromoLeftWeek),
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid PromoRight1 show with items",
|
||||
params: &UpdatePromoParams{
|
||||
Position: PromoRight1,
|
||||
Section: string(PromoRight1Show),
|
||||
Item: []PromoItem{
|
||||
{Title: "Updated title"},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid section for PromoRight1",
|
||||
params: &UpdatePromoParams{
|
||||
Position: PromoRight1,
|
||||
Section: "invalid",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid week in update",
|
||||
params: &UpdatePromoParams{
|
||||
Position: PromoRight1,
|
||||
Section: string(PromoRight1Show),
|
||||
Weeks: []int{0},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "valid weeks in update",
|
||||
params: &UpdatePromoParams{
|
||||
Position: PromoRight1,
|
||||
Section: string(PromoRight1Show),
|
||||
Weeks: []int{5, 6, 7},
|
||||
},
|
||||
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 && tt.params.Item == nil {
|
||||
t.Errorf("expected Item slice to be initialized, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
N. sinistro 2026 7196966
|
||||
|
||||
1. descrizione dell'incidente
|
||||
|
||||
procedevo salendo verso Breno sulla strada cantonale (classificata strada di montagna). Affrontando la curva al tornante che prima di arrivare al negozio "Bottega Breno". La careggiata in questo tornante non ofre visibilità è necessario osservare la massima prudenza, cosa che percorrendo regolarmente questa strada applico regolarmente. Ho affrontato la curva come sempre con prudenza e tenendo la destra in modo adeguato. Essendo la careggiata non larga a sufficienza per incrociare due veicoli senza che uno si debba fermare. Inoltre visto la dimensione ridotta non c'è la segalazione di careggiata. (linea bianca al centro). Il veicolo che procedeva in discesa non mi ha concesso la precedenza, non si è fermato e mi ha urtato la parte frontale sinistra.
|
||||
|
||||
allego alla risposta alcune fotografie indicative e lo schizzo.
|
||||
|
||||
2. secondo lei, a chi è attribuibile la colpa e perche?
|
||||
a mio avviso la colpa è attribuibile al vostro assicurato. Avrebbe dovuto prestare prudenza e rispettare la precedenza.
|
||||
|
||||
Manno 3.07.2026
|
||||
@@ -2,104 +2,34 @@ package extrahome
|
||||
|
||||
import "time"
|
||||
|
||||
type SiteConfig struct {
|
||||
ExtraSite ExtraSite
|
||||
StartAt time.Time
|
||||
Description string
|
||||
type Head struct {
|
||||
Title string
|
||||
Meta []HeadMeta
|
||||
Links []HeadLink
|
||||
Langs []HeadLang
|
||||
Scripts []HeadScript
|
||||
}
|
||||
type HeadMeta struct {
|
||||
Name string
|
||||
Content string
|
||||
}
|
||||
type HeadLink struct {
|
||||
Rel string
|
||||
Href string
|
||||
}
|
||||
type HeadScript struct {
|
||||
Defer string
|
||||
DataDomain string
|
||||
Src string
|
||||
}
|
||||
type HeadLang struct {
|
||||
Rel string
|
||||
Hreflang string
|
||||
Href string
|
||||
}
|
||||
|
||||
type ExtraSite struct {
|
||||
NavItems []NavItem
|
||||
HomePageData HomePageData
|
||||
BroadcasterPageData BroadcasterPageData
|
||||
ContactPageData ContactPageData
|
||||
Carusel Carusel
|
||||
}
|
||||
|
||||
type NavItem struct {
|
||||
Label string
|
||||
Color string
|
||||
URL string
|
||||
}
|
||||
|
||||
type HomePageData struct {
|
||||
Title string
|
||||
Lang string
|
||||
HeroTitle string
|
||||
Profiles []Profile
|
||||
Promo Promo
|
||||
ListOfBroadcasters ListOfBroadcaster
|
||||
Carusel Carusel
|
||||
Year int
|
||||
}
|
||||
|
||||
type BroadcasterPageData struct {
|
||||
Title string
|
||||
Lang string
|
||||
Year int
|
||||
PromoRight1 Promo
|
||||
PromoRight2 Promo
|
||||
Profile BroadcasterProfile
|
||||
}
|
||||
|
||||
type ContactPageData struct {
|
||||
Title string
|
||||
Lang string
|
||||
HeroTitle string
|
||||
Email string
|
||||
Phone string
|
||||
Address string
|
||||
Hours string
|
||||
Message string
|
||||
Year int
|
||||
}
|
||||
|
||||
type Carusel struct {
|
||||
Items []CaruselItem
|
||||
}
|
||||
|
||||
type CaruselItem struct {
|
||||
Title string
|
||||
Description string
|
||||
ImageURL string
|
||||
URL string
|
||||
}
|
||||
|
||||
type Promo struct {
|
||||
Left PromoItem
|
||||
Right1 PromoItem
|
||||
Right2 PromoItem
|
||||
}
|
||||
|
||||
type PromoItem struct {
|
||||
Title string
|
||||
Description string
|
||||
Content []PromoContent
|
||||
}
|
||||
|
||||
type PromoContent struct {
|
||||
Width int
|
||||
Height int
|
||||
MobileOnly bool
|
||||
MobileImageURL string
|
||||
NewWindow bool
|
||||
Title string
|
||||
Description string
|
||||
ImageURL string
|
||||
URL string
|
||||
}
|
||||
|
||||
type ListOfBroadcaster struct {
|
||||
Title string
|
||||
Items []ListOfBroadcasterItem
|
||||
}
|
||||
|
||||
type ListOfBroadcasterItem struct {
|
||||
Title string
|
||||
Category string
|
||||
Description string
|
||||
ImageURL string
|
||||
URL string
|
||||
type SiteLangs map[string][]struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
type Profile struct {
|
||||
@@ -141,3 +71,99 @@ type BroadcasterProfile struct {
|
||||
Languages []string
|
||||
Services []string
|
||||
}
|
||||
|
||||
type ListOfBroadcaster struct {
|
||||
Title string
|
||||
Broadcasters []int64
|
||||
}
|
||||
|
||||
type Carusel struct {
|
||||
Items []CaruselItem
|
||||
}
|
||||
|
||||
type CaruselItem struct {
|
||||
Title string
|
||||
Description string
|
||||
ImageURL string
|
||||
URL string
|
||||
}
|
||||
|
||||
const (
|
||||
PromoLeft = iota
|
||||
PromoRight1
|
||||
PromoRight2
|
||||
)
|
||||
|
||||
type Promo struct {
|
||||
ID int64
|
||||
Position int // Promo position: PromoLeft, PromoRight1, PromoRight2
|
||||
Item []PromoItem
|
||||
ActiveFrom time.Time
|
||||
}
|
||||
|
||||
type PromoItem struct {
|
||||
Title string
|
||||
Content []PromoContent
|
||||
}
|
||||
|
||||
type PromoContent struct {
|
||||
Width int
|
||||
Height int
|
||||
MobileOnly bool
|
||||
MobileImageURL string
|
||||
NewWindow bool
|
||||
ImageURL string
|
||||
URL string
|
||||
}
|
||||
|
||||
type NavItem struct {
|
||||
Label string
|
||||
Color string
|
||||
URL string
|
||||
}
|
||||
|
||||
type HomePageData struct {
|
||||
Title string
|
||||
SiteLangs SiteLangs
|
||||
PromoLeft Promo // Promo position: PromoLeft id
|
||||
PromoRight1 Promo // Promo position: PromoRight1 id
|
||||
PromoRight2 Promo // Promo position: PromoRight2 id
|
||||
ListOfBroadcasters ListOfBroadcaster
|
||||
Carusel Carusel
|
||||
Year int
|
||||
}
|
||||
|
||||
type SiteConfig struct {
|
||||
ExtraSite ExtraSite
|
||||
StartAt time.Time
|
||||
Description string
|
||||
}
|
||||
|
||||
type ExtraSite struct {
|
||||
NavItems []NavItem
|
||||
HomePageData HomePageData
|
||||
BroadcasterPageData BroadcasterPageData
|
||||
ContactPageData ContactPageData
|
||||
Carusel Carusel
|
||||
}
|
||||
|
||||
type BroadcasterPageData struct {
|
||||
Title string
|
||||
Lang string
|
||||
Year int
|
||||
PromoRight1 Promo
|
||||
PromoRight2 Promo
|
||||
Profile BroadcasterProfile
|
||||
}
|
||||
|
||||
type ContactPageData struct {
|
||||
Title string
|
||||
Lang string
|
||||
HeroTitle string
|
||||
Email string
|
||||
Phone string
|
||||
Address string
|
||||
Hours string
|
||||
Message string
|
||||
Year int
|
||||
}
|
||||
|
||||
252
images/images.go
Normal file
252
images/images.go
Normal 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
94
images/images_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
5
images/migrations/1_create_images.up.sql
Normal file
5
images/migrations/1_create_images.up.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE images (
|
||||
uuid UUID PRIMARY KEY,
|
||||
description TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
Reference in New Issue
Block a user