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: 6 additions & 6 deletions backend/docs/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,18 @@ The `docs` agent reads this index first to locate the right file before diving i
| Topic | File | Source files covered |
|---|---|---|
| Startup lifecycle & dependency initialisation | [bootstrap.md](bootstrap.md) | `internal/bootstrap/bootstrap.go`, `internal/server/server.go`, `cmd/api/main.go` |
| Database connection & query patterns | [database.md](database.md) | `internal/infrastructure/database/postgres/db.go`, `internal/infrastructure/database/postgres/health_repository.go`, `internal/domain/health.go`, `internal/usecase/health_usecase.go` |
| Database connection & query patterns | [database.md](database.md) | `internal/infrastructure/database/postgres/db.go`, `internal/infrastructure/database/postgres/health_repository.go`, `internal/infrastructure/database/postgres/user_repository.go`, `internal/domain/health.go`, `internal/domain/user.go`, `internal/domain/pagination.go`, `internal/usecase/health_usecase.go`, `internal/usecase/user.go` |
| Schema migrations (goose) | [migrations.md](migrations.md) | `cmd/migrate/main.go`, `internal/infrastructure/database/migrations/`, `Makefile` |
| HTTP routing & handler patterns | [routing.md](routing.md) | `internal/transport/handlers/handler.go`, `internal/transport/handlers/routes.go`, `internal/transport/handlers/hello_handler.go`, `internal/transport/handlers/health_handler.go`, `internal/transport/middleware/logger.go`, `internal/server/server.go` |
| HTTP routing & handler patterns | [routing.md](routing.md) | `internal/transport/handlers/handler.go`, `internal/transport/handlers/routes.go`, `internal/transport/handlers/hello_handler.go`, `internal/transport/handlers/health_handler.go`, `internal/transport/handlers/me_handler.go`, `internal/transport/handlers/validation.go`, `internal/transport/middleware/logger.go`, `internal/server/server.go` |
| Testing patterns (unit, handler, Redis, bootstrap) | [testing.md](testing.md) | `internal/infrastructure/database/postgres/health_repository_test.go`, `internal/transport/handlers/hello_handler_test.go`, `internal/transport/handlers/health_handler_test.go`, `internal/transport/middleware/logger_test.go`, `internal/usecase/health_usecase_test.go`, `internal/infrastructure/cache/redis/cache_test.go`, `internal/bootstrap/bootstrap_test.go` |
| Error handling conventions | [error-handling.md](error-handling.md) | `internal/infrastructure/database/postgres/health_repository.go`, `internal/transport/handlers/health_handler.go`, `cmd/api/main.go` |
| Error handling conventions | [error-handling.md](error-handling.md) | `internal/infrastructure/database/postgres/health_repository.go`, `internal/transport/handlers/health_handler.go`, `internal/transport/handlers/validation.go`, `cmd/api/main.go` |
| Environment variables | [environment.md](environment.md) | `.env`, `internal/bootstrap/bootstrap.go`, `internal/infrastructure/database/postgres/db.go` |
| Middleware (logger, rate limiter) | [middleware.md](middleware.md) | `internal/transport/middleware/logger.go`, `internal/transport/middleware/ratelimit.go`, `internal/transport/handlers/routes.go` |
| Firebase Auth (token verification, middleware, MeHandler) | [auth.md](auth.md) | `internal/usecase/auth_usecase.go`, `internal/transport/middleware/auth.go`, `internal/transport/handlers/auth_handler.go`, `pkg/firebase/admin.go`, `internal/bootstrap/bootstrap.go` |
| Observability (Sentry error tracking) | [observability.md](observability.md) | `internal/transport/middleware/sentry.go`, `internal/bootstrap/bootstrap.go`, `internal/transport/handlers/routes.go` |
| WebSocket (Hub, client, GET /ws, auth, wiring) | [websocket.md](websocket.md) | `internal/infrastructure/ws/`, `internal/transport/handlers/ws_handler.go`, `internal/transport/handlers/routes.go`, `internal/server/server.go`, `cmd/api/main.go` |
| Background job queue (Asynq, task definitions, worker, Asynqmon UI) | [queue.md](queue.md) | `internal/usecase/enqueuer.go`, `internal/infrastructure/queue/tasks.go`, `internal/infrastructure/queue/client.go`, `internal/infrastructure/queue/worker.go`, `internal/infrastructure/queue/handlers.go`, `internal/transport/handlers/routes.go`, `cmd/api/main.go` |
| 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` |
| WebSocket (Hub, client, GET /ws, auth, bidirectional inbound handling, semaphore) | [websocket.md](websocket.md) | `internal/infrastructure/ws/`, `internal/transport/handlers/ws_handler.go`, `internal/transport/handlers/routes.go`, `internal/server/server.go`, `cmd/api/main.go` |
| Background job queue (Asynq, task definitions, worker, Asynqmon UI, NewHandle* constructor pattern) | [queue.md](queue.md) | `internal/usecase/enqueuer.go`, `internal/infrastructure/queue/tasks.go`, `internal/infrastructure/queue/client.go`, `internal/infrastructure/queue/worker.go`, `internal/infrastructure/queue/handlers.go`, `internal/transport/handlers/routes.go`, `cmd/api/main.go` |
| 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`, `internal/usecase/streams.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` |
Expand Down
80 changes: 77 additions & 3 deletions backend/docs/database.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
---
topic: database
last_verified: 2026-06-15
last_verified: 2026-06-23
sources:
- internal/domain/health.go
- internal/domain/user.go
- internal/domain/pagination.go
- internal/usecase/health_usecase.go
- internal/usecase/user.go
- internal/infrastructure/database/postgres/db.go
- internal/infrastructure/database/postgres/health_repository.go
- internal/infrastructure/database/postgres/user_repository.go
---

# Database
Expand Down Expand Up @@ -45,11 +49,14 @@ Env vars are loaded via `_ "github.com/joho/godotenv/autoload"` blank import in

```
internal/domain/health.go — HealthStats type
internal/domain/user.go — User type
internal/domain/pagination.go — Page[T], CursorPage[T], PageRequest
internal/usecase/health_usecase.go — HealthReader interface (repo contract), HealthUseCase interface + impl
internal/infrastructure/database/postgres/ — HealthRepository: implements HealthReader against *sql.DB
internal/usecase/user.go — UserRepository interface
internal/infrastructure/database/postgres/ — HealthRepository, UserRepository: implement interfaces against *sql.DB
```

The `HealthReader` interface is defined in the `usecase` package (Dependency Inversion — the use case owns the interface it depends on):
Repository interfaces are defined in the `usecase` package (Dependency Inversion — the use case owns the interface it depends on):

```go
// usecase/health_usecase.go
Expand All @@ -60,8 +67,75 @@ type HealthReader interface {
type HealthUseCase interface {
GetHealth(ctx context.Context) (domain.HealthStats, error)
}

// usecase/user.go
type UserRepository interface {
Upsert(ctx context.Context, u *domain.User) (*domain.User, error)
DeleteByFirebaseUID(ctx context.Context, firebaseUID string) error
}
```

## Domain types

```go
// internal/domain/user.go
type User struct {
ID int64 `json:"id"`
FirebaseUID string `json:"firebase_uid"`
Name string `json:"name"`
Email string `json:"email"`
PhotoURL string `json:"photo_url"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

// internal/domain/pagination.go
type Page[T any] struct {
Items []T `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
HasMore bool `json:"has_more"`
}

type CursorPage[T any] struct {
Items []T `json:"items"`
NextCursor string `json:"next_cursor,omitempty"`
HasMore bool `json:"has_more"`
}

type PageRequest struct {
Page int `form:"page" binding:"min=0"`
PageSize int `form:"page_size" binding:"min=0,max=100"`
}

func (p *PageRequest) Defaults() // fills zero values with page=1, size=20
func (p PageRequest) Offset() int // returns SQL OFFSET value
```

## Users table schema
Added via migration `20260623063851_add_user_profile.sql`:

| Column | Type | Constraints |
|---|---|---|
| `id` | `BIGSERIAL` | PRIMARY KEY |
| `firebase_uid` | `TEXT` | NOT NULL, UNIQUE |
| `name` | `TEXT` | — |
| `email` | `TEXT` | — |
| `photo_url` | `TEXT` | — |
| `created_at` | `TIMESTAMPTZ` | NOT NULL DEFAULT now() |
| `updated_at` | `TIMESTAMPTZ` | NOT NULL DEFAULT now() |

## UserRepository
`internal/infrastructure/database/postgres/user_repository.go` — constructor returns the interface, not the concrete type:

```go
func NewUserRepository(db *sql.DB) usecase.UserRepository
```

`Upsert` uses `INSERT ... ON CONFLICT (firebase_uid) DO UPDATE` and returns the full row via `RETURNING`.
`DeleteByFirebaseUID` deletes by `firebase_uid`; does not error when the row does not exist (DELETE is idempotent).

## Repository pattern
Each repository is a struct that holds `*sql.DB` and is constructed with a `New*` function.

Expand Down
21 changes: 20 additions & 1 deletion backend/docs/email.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
---
title: Email (Mailjet)
topic: email
last_verified: 2026-06-23
sources:
- internal/usecase/email.go
- internal/infrastructure/email/mailjet.go
- internal/infrastructure/email/templates/welcome.html
- internal/infrastructure/queue/handlers.go
- internal/infrastructure/queue/tasks.go
- internal/bootstrap/bootstrap.go
- internal/server/server.go
- internal/transport/handlers/handler.go
---

# Email (Mailjet)
Expand Down Expand Up @@ -52,6 +56,21 @@ var templateFS embed.FS

`templates/welcome.html` is a Go `html/template` file. The only template data value currently used is `{{.Name}}` (the recipient's display name). `renderWelcomeTemplate` parses and executes the template on each call and returns the rendered HTML string.

### How welcome email is triggered

The welcome email is sent via the Asynq queue (not directly from a handler). The Asynq
task handler `NewHandleWelcomeEmail(sender)` in `internal/infrastructure/queue/handlers.go`
calls `sender.SendWelcomeEmail` with the name from `WelcomeEmailPayload.Name`.
When `WelcomeEmailPayload.Name` is empty the handler falls back to `WelcomeEmailPayload.Email`
as the display name.

```go
// Triggered when a TypeWelcomeEmail task is dequeued.
func NewHandleWelcomeEmail(sender usecase.EmailSender) asynq.HandlerFunc
```

`sender` is nil when `MAILJET_API_KEY` is not set; the handler no-ops gracefully in that case.

### Wiring (bootstrap)

`bootstrap.Run` constructs the sender when both `MAILJET_API_KEY` and `MAILJET_SECRET_KEY` are non-empty:
Expand Down
18 changes: 14 additions & 4 deletions backend/docs/error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ last_verified: 2026-06-23
sources:
- internal/infrastructure/database/postgres/health_repository.go
- internal/transport/handlers/health_handler.go
- internal/transport/handlers/validation.go
- cmd/api/main.go
---

Expand Down Expand Up @@ -71,16 +72,25 @@ func (h *Handler) getItemHandler(c *gin.Context) {
Never expose internal error messages to clients. Log the original error server-side.

## Request binding errors
Always validate and return 400 on bad input:
Use the shared helpers in `internal/transport/handlers/validation.go` — do not call `c.ShouldBindJSON` / `c.ShouldBindQuery` directly in handlers.

```go
var input MyRequest
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
// internal/transport/handlers/validation.go
func bindJSON(c *gin.Context, dst any) bool // writes 400 {"error":"invalid request body"} on failure
func bindQuery(c *gin.Context, dst any) bool // writes 400 {"error":"invalid query parameters"} on failure
```

Both helpers return `false` and write the 400 response on failure, so the handler just returns immediately:

```go
var req updateMeRequest
if !bindJSON(c, &req) {
return
}
```

The stable message strings (`"invalid request body"`, `"invalid query parameters"`) never expose raw validation errors to the client.

## Error wrapping
Use `fmt.Errorf("context: %w", err)` when adding context to returned errors so callers can use `errors.Is` / `errors.As`.

Expand Down
26 changes: 24 additions & 2 deletions backend/docs/migrations.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
topic: migrations
last_verified: 2026-06-15
last_verified: 2026-06-23
sources:
- cmd/migrate/main.go
- internal/infrastructure/database/migrations/
Expand Down Expand Up @@ -46,10 +46,32 @@ DROP TABLE users;

Rules:
- Every statement must end with `;`
- DDL only — no application data mutations in migrations
- DDL only — no application data mutations in migrations, with one exception: `UPDATE` backfills are permitted as a step between `ADD COLUMN` (nullable) and `SET NOT NULL` (see pattern below)
- For multi-statement blocks (PL/pgSQL), wrap with `-- +goose StatementBegin` / `-- +goose StatementEnd`
- Avoid `-- +goose NO TRANSACTION` unless the statement genuinely cannot run in a transaction (e.g. `CREATE INDEX CONCURRENTLY`)

## Adding a NOT NULL column to an existing table
When adding a `NOT NULL` column to a table that may already have rows, follow the three-step pattern from `20260623063851_add_user_profile.sql`:

```sql
-- +goose Up
-- Step 1: add as nullable
ALTER TABLE users ADD COLUMN IF NOT EXISTS firebase_uid TEXT;

-- Step 2: backfill existing rows so SET NOT NULL will succeed
UPDATE users SET firebase_uid = 'legacy-' || id::text WHERE firebase_uid IS NULL;

-- Step 3: apply the constraint and unique index
ALTER TABLE users ALTER COLUMN firebase_uid SET NOT NULL;
ALTER TABLE users ADD CONSTRAINT users_firebase_uid_key UNIQUE (firebase_uid);

-- +goose Down
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_firebase_uid_key;
ALTER TABLE users DROP COLUMN IF EXISTS firebase_uid;
```

This avoids the `ERROR: column contains null values` failure that occurs when you add a `NOT NULL` column with no default and the table is non-empty.

## Workflow for any new table
1. `make migrate-create name=add_<table>` — generates the timestamped file
2. Fill in `CREATE TABLE` (Up) and `DROP TABLE` (Down)
Expand Down
41 changes: 23 additions & 18 deletions backend/docs/queue.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
topic: queue
last_verified: 2026-06-15
last_verified: 2026-06-23
sources:
- internal/usecase/enqueuer.go
- internal/infrastructure/queue/tasks.go
Expand Down Expand Up @@ -36,9 +36,12 @@ const (
type WelcomeEmailPayload struct {
UserID string `json:"user_id"`
Email string `json:"email"`
Name string `json:"name"`
}
```

`Name` is used by `NewHandleWelcomeEmail` as the display name in the welcome email. When empty, the handler falls back to `Email`.

Both the enqueuer (caller side) and the handler (worker side) import these constants so
the string is never duplicated.

Expand Down Expand Up @@ -116,7 +119,7 @@ if app.Config.RedisURL != "" {
workerCancel = wCancel
worker, err := queue.NewWorker(app.Config.RedisURL)
// ...
worker.Register(queue.TypeWelcomeEmail, asynq.HandlerFunc(queue.HandleWelcomeEmail))
worker.Register(queue.TypeWelcomeEmail, queue.NewHandleWelcomeEmail(app.EmailSender))
go func() {
if err := worker.Run(workerCtx); err != nil {
slog.Error("queue: worker error", "err", err)
Expand All @@ -139,31 +142,32 @@ can still reach the hub during drain.

## Task handlers

Handler functions have the signature `func(context.Context, *asynq.Task) error` and live
in `internal/infrastructure/queue/handlers.go`:
Handler functions live in `internal/infrastructure/queue/handlers.go`.
They are constructed via a `New*` function that closes over dependencies (e.g. `EmailSender`),
rather than being plain `func(context.Context, *asynq.Task) error` functions.

```go
func HandleWelcomeEmail(_ context.Context, t *asynq.Task) error {
var p WelcomeEmailPayload
if err := json.Unmarshal(t.Payload(), &p); err != nil {
return fmt.Errorf("welcome email: unmarshal payload: %w", err)
}
slog.Info("queue: welcome email task received", "user_id", p.UserID, "email", p.Email)
return nil
}
// NewHandleWelcomeEmail returns an asynq.HandlerFunc that sends a welcome email via sender.
// If sender is nil, the task is acknowledged without sending (graceful degradation).
func NewHandleWelcomeEmail(sender usecase.EmailSender) asynq.HandlerFunc
```

Internally it unmarshals `WelcomeEmailPayload`, falls back to `p.Email` when `p.Name` is
empty, then calls `sender.SendWelcomeEmail`. A nil `sender` is accepted silently so the
worker starts cleanly even when Mailjet is not configured.

Returning a non-nil error causes Asynq to retry the task (up to its configured retry limit).

## Adding a new task

1. Add a `Type<Name> = "<category>:<action>"` constant and `<Name>Payload` struct to
`internal/infrastructure/queue/tasks.go`.
2. Write a `Handle<Name>(ctx context.Context, t *asynq.Task) error` function in
`internal/infrastructure/queue/handlers.go`.
2. Write a `NewHandle<Name>(dep SomeDep) asynq.HandlerFunc` constructor in
`internal/infrastructure/queue/handlers.go`. Close over any dependencies (e.g. `EmailSender`).
The returned `HandlerFunc` should gracefully no-op when a nil dependency is passed.
3. Register the handler in `cmd/api/main.go`:
```go
worker.Register(queue.Type<Name>, asynq.HandlerFunc(queue.Handle<Name>))
worker.Register(queue.Type<Name>, queue.NewHandle<Name>(dep))
```
4. Enqueue from the relevant use case or handler using `app.Enqueuer.Enqueue(ctx, queue.Type<Name>, payload)`.

Expand All @@ -189,12 +193,13 @@ The UI is not mounted in staging or production (`gin.ReleaseMode`).

## Testing

**Unit tests** call handler functions directly with `asynq.NewTask` — no Redis required:
**Unit tests** call the constructed handler directly with `asynq.NewTask` — no Redis required:

```go
payload, _ := json.Marshal(queue.WelcomeEmailPayload{UserID: "u1", Email: "a@b.com"})
payload, _ := json.Marshal(queue.WelcomeEmailPayload{UserID: "u1", Email: "a@b.com", Name: "Alice"})
task := asynq.NewTask(queue.TypeWelcomeEmail, payload)
err := queue.HandleWelcomeEmail(context.Background(), task)
handler := queue.NewHandleWelcomeEmail(mockSender) // or nil to test graceful degradation
err := handler(context.Background(), task)
// assert err == nil
```

Expand Down
Loading
Loading