Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,14 @@ MAILJET_API_KEY=your_mailjet_api_key_here
MAILJET_SECRET_KEY=your_mailjet_secret_key_here
# Sender identity — must be a verified Mailjet sender address
FROM_EMAIL=no-reply@example.com
FROM_NAME=MyApp
FROM_NAME=MyApp
# Cloudflare R2 object storage (optional; omit or leave empty to disable file storage)
# Account ID from: Cloudflare Dashboard → R2 → Overview
R2_ACCOUNT_ID=your_r2_account_id_here
# API token credentials (create from: Cloudflare Dashboard → R2 → Manage API Tokens)
R2_ACCESS_KEY=your_r2_access_key_here
R2_SECRET_KEY=your_r2_secret_key_here
# The name of your R2 bucket
R2_BUCKET=your_bucket_name_here
# Public URL for the bucket (set up a custom domain or use r2.dev subdomain)
R2_PUBLIC_URL=https://pub-xxxxxxxxxxxx.r2.dev
1 change: 1 addition & 0 deletions backend/docs/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ The `docs` agent reads this index first to locate the right file before diving i
| Redis Streams event fan-out (producer, consumer, consumer groups) | [streams.md](streams.md) | `internal/infrastructure/streams/events.go`, `internal/infrastructure/streams/producer.go`, `internal/infrastructure/streams/consumer.go` |
| Firebase Cloud Messaging — token storage, send API, FCM endpoints | [fcm.md](fcm.md) | `internal/domain/fcm_token.go`, `internal/usecase/notification.go`, `internal/infrastructure/database/postgres/fcm_token_repository.go`, `internal/transport/handlers/fcm_handler.go`, `pkg/firebase/app.go`, `pkg/firebase/messaging.go` |
| Transactional email (Mailjet) — EmailSender interface, MailjetSender, sandbox mode, templates | [email.md](email.md) | `internal/usecase/email.go`, `internal/infrastructure/email/mailjet.go`, `internal/infrastructure/email/templates/welcome.html`, `internal/bootstrap/bootstrap.go`, `internal/server/server.go`, `internal/transport/handlers/handler.go` |
| Object storage (Cloudflare R2) — StorageService interface, R2 implementation, presign/delete endpoints | [storage.md](storage.md) | `internal/usecase/storage.go`, `internal/infrastructure/storage/r2/storage.go`, `internal/transport/handlers/storage_handler.go`, `internal/transport/handlers/routes.go`, `internal/bootstrap/bootstrap.go` |
31 changes: 21 additions & 10 deletions backend/docs/bootstrap.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,18 @@ sources:
## App struct
```go
type App struct {
DB *sql.DB
Cache usecase.CacheService // nil when REDIS_URL is not set
Enqueuer usecase.Enqueuer // nil when REDIS_URL is not set
Firebase usecase.FirebaseAdminClient // nil when FIREBASE_PROJECT_ID is not set
FCMSender usecase.NotificationSender // nil when FIREBASE_PROJECT_ID is not set
EmailSender usecase.EmailSender // nil when MAILJET_API_KEY/SECRET_KEY are not set
Config Config
Log *slog.Logger
DB *sql.DB
Cache usecase.CacheService // nil when REDIS_URL is not set
Enqueuer usecase.Enqueuer // nil when REDIS_URL is not set
Firebase usecase.FirebaseAdminClient // nil when FIREBASE_PROJECT_ID is not set
FCMSender usecase.NotificationSender // nil when FIREBASE_PROJECT_ID is not set
EmailSender usecase.EmailSender // nil when MAILJET_API_KEY is not set
StorageService usecase.StorageService // nil when R2_ACCOUNT_ID is not set
Config Config
Log *slog.Logger
}
```
`App` is constructed once by `Run` and passed to `server.NewServer`. Nothing re-initialises dependencies after this point. Optional fields (`Cache`, `Enqueuer`, `Firebase`, `FCMSender`, `EmailSender`) are nil when their corresponding env vars are absent.
`App` is constructed once by `Run` and passed to `server.NewServer`. Nothing re-initialises dependencies after this point. Optional fields (`Cache`, `Enqueuer`, `Firebase`, `FCMSender`, `EmailSender`, `StorageService`) are nil when their corresponding env vars are absent.

## Config struct
```go
Expand All @@ -39,6 +40,15 @@ type Config struct {
FirebaseProjectID string
FirebaseServiceAccountJSON string
SentryDSN string
MailjetAPIKey string
MailjetSecretKey string
FromEmail string
FromName string
R2AccountID string
R2AccessKey string
R2SecretKey string
R2Bucket string
R2PublicURL string
}
```
`loadConfig()` reads all values from environment variables. `PORT` defaults to `8080`; `BLUEPRINT_DB_SCHEMA` defaults to `public`; `BLUEPRINT_DB_SSLMODE` defaults to `disable`. `RateLimitBurst` is derived as `int(RPS)*5` when omitted and RPS is set. Optional fields (`RedisURL`, `FirebaseProjectID`, `FirebaseServiceAccountJSON`) default to empty string — their respective services are skipped when empty.
Expand All @@ -54,7 +64,8 @@ type Config struct {
6. Init Asynq enqueuer via `queue.NewClient(cfg.RedisURL)` — skipped when `REDIS_URL` is empty
7. Init Firebase app via `firebase.NewApp(ctx, ...)`, then init Auth client (`firebase.NewAuthClient`) and FCM messaging client (`firebase.NewMessagingClient`) from the same app instance — all skipped when `FIREBASE_PROJECT_ID` is empty
8. Init Mailjet email sender via `email.NewMailjetSender(...)` — skipped when `MAILJET_API_KEY` or `MAILJET_SECRET_KEY` is empty; startup fails if only a partial Mailjet config is provided
9. Return `*App` on success; return a non-nil error on any failure
9. Init R2 storage client via `r2.New(...)` — skipped when `R2_ACCOUNT_ID` is empty; startup fails if `R2_ACCOUNT_ID` is set but any other R2 var is missing
10. Return `*App` on success; return a non-nil error on any failure

```go
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
Expand Down
5 changes: 5 additions & 0 deletions backend/docs/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ This runs on package init before any env var is read — no explicit `godotenv.L
| `MAILJET_SECRET_KEY` | `bootstrap.go` | — | Mailjet secret key. Must be provided alongside `MAILJET_API_KEY`. |
| `FROM_EMAIL` | `bootstrap.go` | — | Verified Mailjet sender address (e.g. `no-reply@example.com`). Required when `MAILJET_API_KEY` and `MAILJET_SECRET_KEY` are set; startup fails if omitted. |
| `FROM_NAME` | `bootstrap.go` | — | Sender display name (e.g. `MyApp`). Only read when both Mailjet credentials are set. |
| `R2_ACCOUNT_ID` | `bootstrap.go` | — | Cloudflare account ID. When omitted, `App.StorageService` is `nil` and storage routes are not registered. |
| `R2_ACCESS_KEY` | `bootstrap.go` | — | R2 API token access key. Required when `R2_ACCOUNT_ID` is set; startup fails if omitted. |
| `R2_SECRET_KEY` | `bootstrap.go` | — | R2 API token secret key. Required when `R2_ACCOUNT_ID` is set; startup fails if omitted. |
| `R2_BUCKET` | `bootstrap.go` | — | R2 bucket name. Required when `R2_ACCOUNT_ID` is set; startup fails if omitted. |
| `R2_PUBLIC_URL` | `bootstrap.go` | — | Public base URL for the R2 bucket (custom domain or `r2.dev` subdomain). Required when `R2_ACCOUNT_ID` is set; startup fails if omitted. |

Variables marked **required** are validated by `bootstrap.validateConfig` at startup — the process exits before attempting a DB connection if any are missing.

Expand Down
4 changes: 2 additions & 2 deletions backend/docs/error-handling.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
topic: error-handling
last_verified: 2026-06-15
last_verified: 2026-06-23
sources:
- internal/infrastructure/database/postgres/health_repository.go
- internal/transport/handlers/health_handler.go
Expand All @@ -19,7 +19,7 @@ Never use `log.Fatal` or `os.Exit` inside `internal/`.
| `cmd/api/main.go: main()` | `fmt.Fprintf(os.Stderr, ...) + os.Exit(1)` | `bootstrap.Run()` returned an error — process cannot start |

This is the only permitted early-exit path and it lives in `cmd/`, not `internal/`.
`server.NewServer` does not return an error — all fallible startup work is done by `bootstrap.Run`.
`server.NewServer` returns `(*http.Server, error)` — the caller in `cmd/api/main.go` checks the error and exits on failure. Fallible startup work is split between `bootstrap.Run` and `server.NewServer` (e.g. registering Prometheus collectors).

## Repository errors
Repository methods return `(Result, error)`. On failure, wrap with context using `fmt.Errorf`:
Expand Down
2 changes: 1 addition & 1 deletion backend/docs/routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ if app.Config.RedisURL != "" {
// parse URL and build asynqmon.New(...)
}

h := handlers.NewHandler(healthUC, app.Firebase, hub, app.Enqueuer, queueUI, app.FCMSender, fcmTokenRepo)
h := handlers.NewHandler(healthUC, app.Firebase, hub, app.Enqueuer, queueUI, app.FCMSender, fcmTokenRepo, app.EmailSender)

// Register DB pool metrics collector (AlreadyRegisteredError is silenced).
prometheus.Register(postgres.NewDBStatsCollector(app.DB))
Expand Down
137 changes: 137 additions & 0 deletions backend/docs/storage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
---
topic: storage
last_verified: 2026-06-23
sources:
- backend/internal/usecase/storage.go
- backend/internal/infrastructure/storage/r2/storage.go
- backend/internal/transport/handlers/storage_handler.go
- backend/internal/transport/handlers/routes.go
- backend/internal/bootstrap/bootstrap.go
---

# Storage (Cloudflare R2)

## Overview
R2 is the optional object storage backend. The integration is enabled only when `R2_ACCOUNT_ID` is set in the environment. When absent, `App.StorageService` is `nil` and the storage routes are not registered.

## Interface
`usecase.StorageService` is defined in `internal/usecase/storage.go` and sits at the use-case layer to keep the transport and infrastructure layers decoupled from any concrete SDK:

```go
type StorageService interface {
PresignUpload(ctx context.Context, key string, contentType string, ttl time.Duration) (string, error)
Delete(ctx context.Context, key string) error
PublicURL(key string) string
}
```

## R2 implementation
Package `internal/infrastructure/storage/r2` provides the concrete implementation using the AWS SDK v2 (R2 is S3-compatible).

### Constructors

```go
// Production — uses default AWS SDK HTTP client.
func New(accountID, accessKey, secretKey, bucket, publicBaseURL string) (usecase.StorageService, error)

// Test-friendly — accepts a custom *http.Client to inject a mock transport.
// Pass nil to behave identically to New.
func NewWithHTTPClient(accountID, accessKey, secretKey, bucket, publicBaseURL string, httpClient *http.Client) (usecase.StorageService, error)
```

Both constructors return an error if any argument is empty. The R2 endpoint is derived as:
```
https://<accountID>.r2.cloudflarestorage.com
```
The S3 client is configured with `UsePathStyle: true` and region `"auto"`.

### Method behaviour
- `PresignUpload` calls `s3.PresignClient.PresignPutObject` and returns the signed URL. TTL is caller-controlled; the handler passes `15 * time.Minute`.
- `Delete` calls `s3.Client.DeleteObject` directly (no presigning).
- `PublicURL` returns `publicBaseURL + "/" + url.PathEscape(key)`.

## HTTP endpoints

Both routes live under the `/api/v1` group, which applies `FirebaseAuth` middleware when `h.verifier != nil`. They are only registered when `h.storageService != nil`.

```go
if h.storageService != nil {
api.POST("/storage/presign", h.PresignHandler)
api.DELETE("/storage/:key", h.DeleteObjectHandler)
}
```

### POST /api/v1/storage/presign
Returns a presigned PUT URL for the client to upload directly to R2, plus the resulting public URL.

Request body (`presignRequest`):
```json
{ "filename": "avatar.png", "content_type": "image/png" }
```

Response body (`presignResponse`):
```json
{ "upload_url": "https://...", "public_url": "https://pub-xxx.r2.dev/avatar.png" }
```

The `filename` field is used as-is as the R2 object key. The presigned URL expires in 15 minutes.

Responses: `200 OK` | `400 Bad Request` (binding failure) | `500 Internal Server Error`

### DELETE /api/v1/storage/:key
Deletes the object with the given key from R2.

Responses: `204 No Content` | `500 Internal Server Error`

## Bootstrap wiring
In `bootstrap.Run`:
```go
var storageService usecase.StorageService
if cfg.R2AccountID != "" {
svc, err := r2.New(cfg.R2AccountID, cfg.R2AccessKey, cfg.R2SecretKey, cfg.R2Bucket, cfg.R2PublicURL)
if err != nil {
return nil, fmt.Errorf("bootstrap: r2: %w", err)
}
storageService = svc
log.Info("bootstrap: R2 storage client initialised", "bucket", cfg.R2Bucket)
}
```
No `validateConfig` checks guard the R2 block — if `R2_ACCOUNT_ID` is non-empty but other R2 vars are empty, `r2.New` returns an error that aborts startup.

## Environment variables

| Variable | Required | Description |
|---|---|---|
| `R2_ACCOUNT_ID` | Conditional — presence enables the feature | Cloudflare account ID. Found in the R2 dashboard overview. |
| `R2_ACCESS_KEY` | Required when `R2_ACCOUNT_ID` is set | R2 API token access key. |
| `R2_SECRET_KEY` | Required when `R2_ACCOUNT_ID` is set | R2 API token secret key. |
| `R2_BUCKET` | Required when `R2_ACCOUNT_ID` is set | Name of the R2 bucket. |
| `R2_PUBLIC_URL` | Required when `R2_ACCOUNT_ID` is set | Public base URL for the bucket (custom domain or `r2.dev` subdomain). |

## Testing

### Handler unit tests
Handler tests inject a `mockStorageService` struct that implements `usecase.StorageService`. No real R2 credentials or network calls are needed:

```go
type mockStorageService struct {
presignURL string
publicURL string
presignErr error
deleteErr error
}
func (m *mockStorageService) PresignUpload(_ context.Context, _ string, _ string, _ time.Duration) (string, error) {
return m.presignURL, m.presignErr
}
func (m *mockStorageService) Delete(_ context.Context, _ string) error { return m.deleteErr }
func (m *mockStorageService) PublicURL(_ string) string { return m.publicURL }
```

### r2 package tests
Tests in `internal/infrastructure/storage/r2/` use `NewWithHTTPClient` with a custom `http.RoundTripper` to intercept S3 and presign requests without making real network calls:

```go
transport := &mockTransport{handler: func(r *http.Request) *http.Response { ... }}
svc, _ := r2.NewWithHTTPClient("acct", "key", "secret", "bucket", "https://pub.example.com",
&http.Client{Transport: transport})
```
119 changes: 119 additions & 0 deletions backend/docs/swagger/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,99 @@ const docTemplate = `{
}
}
},
"/api/v1/storage/presign": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "Returns a presigned PUT URL and the final public URL. The client uploads directly to R2 using the presigned URL.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"storage"
],
"summary": "Request a presigned upload URL",
"parameters": [
{
"description": "Upload request",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/handlers.presignRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handlers.presignResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/storage/{key}": {
"delete": {
"security": [
{
"BearerAuth": []
}
],
"tags": [
"storage"
],
"summary": "Delete a stored object",
"parameters": [
{
"type": "string",
"description": "Object key",
"name": "key",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/health": {
"get": {
"produces": [
Expand Down Expand Up @@ -386,6 +479,32 @@ const docTemplate = `{
}
}
},
"handlers.presignRequest": {
"type": "object",
"required": [
"content_type",
"filename"
],
"properties": {
"content_type": {
"type": "string"
},
"filename": {
"type": "string"
}
}
},
"handlers.presignResponse": {
"type": "object",
"properties": {
"public_url": {
"type": "string"
},
"upload_url": {
"type": "string"
}
}
},
"handlers.registerFCMTokenRequest": {
"type": "object",
"required": [
Expand Down
Loading
Loading