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

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -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

View File

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

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

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

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

View File

@@ -0,0 +1 @@
ALTER TABLE promo ADD COLUMN section VARCHAR(50) NOT NULL DEFAULT '';

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

View File

@@ -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

View File

@@ -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
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()
);

431
mysql.sql

File diff suppressed because one or more lines are too long