From 1573304006f3fa18e1ca4fa461f8b1ff0e9dde91 Mon Sep 17 00:00:00 2001 From: GRACENOBLE Date: Tue, 23 Jun 2026 12:52:02 +0300 Subject: [PATCH 1/3] feat(web): establish feature-based architecture and folder structure Create directory skeleton for components/, features/, server/, types/, and app route groups (auth)/(dashboard). Update AGENTS.md to document the new layout so subsequent features have a clear home. Closes #34 --- web/AGENTS.md | 26 ++++++++++++++++++-------- web/app/(auth)/.gitkeep | 0 web/app/(dashboard)/.gitkeep | 0 web/components/common/.gitkeep | 0 web/components/home/.gitkeep | 0 web/components/layout/.gitkeep | 0 web/components/ui/.gitkeep | 0 web/features/.gitkeep | 0 web/server/.gitkeep | 0 web/types/.gitkeep | 0 10 files changed, 18 insertions(+), 8 deletions(-) create mode 100644 web/app/(auth)/.gitkeep create mode 100644 web/app/(dashboard)/.gitkeep create mode 100644 web/components/common/.gitkeep create mode 100644 web/components/home/.gitkeep create mode 100644 web/components/layout/.gitkeep create mode 100644 web/components/ui/.gitkeep create mode 100644 web/features/.gitkeep create mode 100644 web/server/.gitkeep create mode 100644 web/types/.gitkeep diff --git a/web/AGENTS.md b/web/AGENTS.md index 47c2275..d97a0f8 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -24,14 +24,24 @@ pnpm test:ui # Vitest browser UI ## Project structure ``` -app/ # Next.js App Router — routes only - layout.tsx # root layout: Geist fonts, global CSS, / - page.tsx # home page (Server Component) - globals.css # Tailwind v4 import + CSS variable definitions -public/ # static assets -components/ # shared UI components (create when needed) -lib/ # utilities and non-React helpers (create when needed) -types/ # shared TypeScript type definitions (create when needed) +app/ # Next.js App Router — routes only (no business logic) + layout.tsx # root layout: Geist fonts, global CSS, / + page.tsx # home page (Server Component) + globals.css # Tailwind v4 import + CSS variable definitions +components/ + layout/ # AppShell, Sidebar, Navbar, Footer + common/ # reusable cross-feature UI (Avatar, Badge, Spinner…) + home/ # landing / home page components + ui/ # shadcn primitives — populated by CLI, do not hand-create +features/ # one subfolder per domain feature (auth/, notifications/…) + / + components/ # feature-scoped UI + hooks/ # feature-scoped React hooks + types.ts # feature-scoped TypeScript types +lib/ # pure utilities and non-React helpers +public/ # static assets +server/ # tRPC routers and server-side logic +types/ # shared TypeScript interfaces used across features ``` --- diff --git a/web/app/(auth)/.gitkeep b/web/app/(auth)/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/web/app/(dashboard)/.gitkeep b/web/app/(dashboard)/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/web/components/common/.gitkeep b/web/components/common/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/web/components/home/.gitkeep b/web/components/home/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/web/components/layout/.gitkeep b/web/components/layout/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/web/components/ui/.gitkeep b/web/components/ui/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/web/features/.gitkeep b/web/features/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/web/server/.gitkeep b/web/server/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/web/types/.gitkeep b/web/types/.gitkeep new file mode 100644 index 0000000..e69de29 From 313e0534fb17848fc2e5395bc16114fee543c8d7 Mon Sep 17 00:00:00 2001 From: GRACENOBLE Date: Tue, 23 Jun 2026 12:56:33 +0300 Subject: [PATCH 2/3] docs(web): document app route groups in AGENTS.md structure tree Co-Authored-By: Claude Sonnet 4.6 --- web/AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/AGENTS.md b/web/AGENTS.md index d97a0f8..3033cd6 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -28,6 +28,8 @@ app/ # Next.js App Router — routes only (no business lo layout.tsx # root layout: Geist fonts, global CSS, / page.tsx # home page (Server Component) globals.css # Tailwind v4 import + CSS variable definitions + (auth)/ # route group for unauthenticated pages (login, register…) + (dashboard)/ # route group for authenticated/protected pages components/ layout/ # AppShell, Sidebar, Navbar, Footer common/ # reusable cross-feature UI (Avatar, Badge, Spinner…) From 9b600b9e28d957e520c4d88afcd41f95eac821fa Mon Sep 17 00:00:00 2001 From: GRACENOBLE Date: Tue, 23 Jun 2026 12:57:28 +0300 Subject: [PATCH 3/3] docs(app) update docs for future work --- backend/docs/_index.md | 12 ++--- backend/docs/database.md | 80 +++++++++++++++++++++++++++++++-- backend/docs/email.md | 21 ++++++++- backend/docs/error-handling.md | 18 ++++++-- backend/docs/migrations.md | 26 ++++++++++- backend/docs/queue.md | 41 +++++++++-------- backend/docs/routing.md | 54 +++++++++++++++++----- backend/docs/streams.md | 76 +++++++++++++++++++++++++------ backend/docs/websocket.md | 82 ++++++++++++++++++++++++---------- 9 files changed, 327 insertions(+), 83 deletions(-) diff --git a/backend/docs/_index.md b/backend/docs/_index.md index 0862d73..a75c592 100644 --- a/backend/docs/_index.md +++ b/backend/docs/_index.md @@ -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` | diff --git a/backend/docs/database.md b/backend/docs/database.md index 50e8bce..23918a3 100644 --- a/backend/docs/database.md +++ b/backend/docs/database.md @@ -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 @@ -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 @@ -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. diff --git a/backend/docs/email.md b/backend/docs/email.md index b0925cc..b8e27c7 100644 --- a/backend/docs/email.md +++ b/backend/docs/email.md @@ -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) @@ -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: diff --git a/backend/docs/error-handling.md b/backend/docs/error-handling.md index 4cd6004..c0ab667 100644 --- a/backend/docs/error-handling.md +++ b/backend/docs/error-handling.md @@ -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 --- @@ -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`. diff --git a/backend/docs/migrations.md b/backend/docs/migrations.md index 39f9b7e..0365023 100644 --- a/backend/docs/migrations.md +++ b/backend/docs/migrations.md @@ -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/ @@ -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_` — generates the timestamped file 2. Fill in `CREATE TABLE` (Up) and `DROP TABLE` (Down) diff --git a/backend/docs/queue.md b/backend/docs/queue.md index abc6129..0e4158a 100644 --- a/backend/docs/queue.md +++ b/backend/docs/queue.md @@ -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 @@ -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. @@ -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) @@ -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 = ":"` constant and `Payload` struct to `internal/infrastructure/queue/tasks.go`. -2. Write a `Handle(ctx context.Context, t *asynq.Task) error` function in - `internal/infrastructure/queue/handlers.go`. +2. Write a `NewHandle(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, asynq.HandlerFunc(queue.Handle)) + worker.Register(queue.Type, queue.NewHandle(dep)) ``` 4. Enqueue from the relevant use case or handler using `app.Enqueuer.Enqueue(ctx, queue.Type, payload)`. @@ -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 ``` diff --git a/backend/docs/routing.md b/backend/docs/routing.md index 37df7ed..84a1e12 100644 --- a/backend/docs/routing.md +++ b/backend/docs/routing.md @@ -7,6 +7,8 @@ sources: - internal/transport/handlers/hello_handler.go - internal/transport/handlers/health_handler.go - internal/transport/handlers/auth_handler.go + - internal/transport/handlers/me_handler.go + - internal/transport/handlers/validation.go - internal/transport/middleware/logger.go - internal/server/server.go - cmd/api/main.go @@ -18,14 +20,18 @@ sources: ```go // internal/transport/handlers/handler.go type Handler struct { - healthUC usecase.HealthUseCase - verifier usecase.FirebaseTokenVerifier // nil disables auth (dev only) - hub *ws.Hub - enqueuer usecase.Enqueuer // nil when REDIS_URL is not set - queueUI http.Handler // nil disables /admin/queues route - fcmSender usecase.NotificationSender // nil when Firebase is not configured - fcmTokenRepo usecase.FCMTokenRepository // nil when Firebase is not configured - emailSender usecase.EmailSender // nil when MAILJET_API_KEY/SECRET_KEY are not set + healthUC usecase.HealthUseCase + verifier usecase.FirebaseTokenVerifier // nil disables auth (dev only) + hub *ws.Hub + enqueuer usecase.Enqueuer // nil when REDIS_URL is not set + queueUI http.Handler // nil disables /admin/queues route + fcmSender usecase.NotificationSender // nil when Firebase is not configured + fcmTokenRepo usecase.FCMTokenRepository // nil when Firebase is not configured + emailSender usecase.EmailSender // nil when MAILJET_API_KEY is not set + storageService usecase.StorageService // nil when R2_ACCOUNT_ID is not set + geoLocator usecase.GeoLocator // nil when geo client is not configured + streamProducer usecase.StreamProducer // nil when REDIS_URL is not set + userRepo usecase.UserRepository // nil when not wired } func NewHandler( @@ -37,9 +43,13 @@ func NewHandler( fcmSender usecase.NotificationSender, fcmTokenRepo usecase.FCMTokenRepository, emailSender usecase.EmailSender, + storageService usecase.StorageService, + geoLocator usecase.GeoLocator, + streamProducer usecase.StreamProducer, + userRepo usecase.UserRepository, ) *Handler ``` -The `Handler` struct holds use case interfaces and infrastructure dependencies — not `*sql.DB` directly. `verifier` is stored on the struct (not passed to `RegisterRoutes`) so the WebSocket handler can read it inline for query-param auth. `fcmTokenRepo` and `fcmSender` are nil when `FIREBASE_PROJECT_ID` is not set; their routes are only registered when non-nil. +The `Handler` struct holds use case interfaces and infrastructure dependencies — not `*sql.DB` directly. `verifier` is stored on the struct (not passed to `RegisterRoutes`) so the WebSocket handler can read it inline for query-param auth. `fcmTokenRepo` and `fcmSender` are nil when `FIREBASE_PROJECT_ID` is not set; their routes are only registered when non-nil. `userRepo` is always wired (constructed unconditionally in `server.NewServer`). ## Wiring (server.go) `internal/server/server.go` contains `NewServer(app *bootstrap.App, hub *ws.Hub) (*http.Server, error)` — wiring only, no logic. @@ -54,12 +64,14 @@ if app.Firebase != nil { fcmTokenRepo = postgres.NewFCMTokenRepository(app.DB) } +userRepo := postgres.NewUserRepository(app.DB) + var queueUI http.Handler if app.Config.RedisURL != "" { // parse URL and build asynqmon.New(...) } -h := handlers.NewHandler(healthUC, app.Firebase, hub, app.Enqueuer, queueUI, app.FCMSender, fcmTokenRepo, app.EmailSender) +h := handlers.NewHandler(healthUC, app.Firebase, hub, app.Enqueuer, queueUI, app.FCMSender, fcmTokenRepo, app.EmailSender, app.StorageService, app.GeoLocator, app.StreamProducer, userRepo) // Register DB pool metrics collector (AlreadyRegisteredError is silenced). prometheus.Register(postgres.NewDBStatsCollector(app.DB)) @@ -119,13 +131,25 @@ func (h *Handler) RegisterRoutes(rps float64, burst int, sentryDSN string) http. if h.verifier != nil { api.Use(middleware.FirebaseAuth(h.verifier)) } + if h.geoLocator != nil { + api.Use(middleware.GeoFromRequest(h.geoLocator)) + } api.GET("/me", h.MeHandler) + if h.userRepo != nil { + api.PATCH("/me", h.UpdateMeHandler) + api.DELETE("/me", h.DeleteMeHandler) + } if h.fcmTokenRepo != nil { api.POST("/fcm/register", h.RegisterFCMToken) api.DELETE("/fcm/unregister", h.UnregisterFCMToken) } + if h.storageService != nil { + api.POST("/storage/presign", h.PresignHandler) + api.DELETE("/storage/:key", h.DeleteObjectHandler) + } + return r } ``` @@ -160,10 +184,16 @@ Allowed methods: GET, POST, PUT, DELETE, OPTIONS, PATCH. | GET | `/swagger/*any` | none | Swagger UI | `routes.go` | | GET | `/admin/queues` | none (debug mode only) | Asynqmon job-monitoring UI | `routes.go` | | GET | `/api/v1/me` | FirebaseAuth header | `MeHandler` — returns verified `FirebaseToken` claims | `auth_handler.go` | +| PATCH | `/api/v1/me` | FirebaseAuth header | `UpdateMeHandler` — upserts user profile; returns `domain.User` | `me_handler.go` | +| DELETE | `/api/v1/me` | FirebaseAuth header | `DeleteMeHandler` — deletes user profile record; 204 on success | `me_handler.go` | | POST | `/api/v1/fcm/register` | FirebaseAuth header | `RegisterFCMToken` — stores device FCM token | `fcm_handler.go` | | DELETE | `/api/v1/fcm/unregister` | FirebaseAuth header | `UnregisterFCMToken` — removes device FCM token | `fcm_handler.go` | +| POST | `/api/v1/storage/presign` | FirebaseAuth header | `PresignHandler` — returns a presigned R2 upload URL | `storage_handler.go` | +| DELETE | `/api/v1/storage/:key` | FirebaseAuth header | `DeleteObjectHandler` — deletes an R2 object | `storage_handler.go` | FCM routes are only registered when `h.fcmTokenRepo != nil` (i.e., `FIREBASE_PROJECT_ID` is set). +Storage routes are only registered when `h.storageService != nil` (i.e., `R2_ACCOUNT_ID` is set). +`PATCH /api/v1/me` and `DELETE /api/v1/me` are registered when `h.userRepo != nil` (always wired). ## Graceful shutdown Wired in `cmd/api/main.go` via `signal.NotifyContext` for SIGINT/SIGTERM. @@ -181,5 +211,5 @@ Do not add shutdown logic to `internal/` — it belongs in `cmd/`. 6. Register the route in `RegisterRoutes()`: `r.METHOD("/path", h.handlerName)`. 7. Add handler as `func (h *Handler) handlerName(c *gin.Context)` in its own file. 8. Always pass `c.Request.Context()` to use case calls. -9. For request body: bind with `c.ShouldBindJSON(&input)`, return 400 on error. -10. For path params: `c.Param("id")`. For query params: `c.Query("key")`. +9. For request body: use the `bindJSON(c, &input) bool` helper (returns `false` and writes 400 on error). For query params: use `bindQuery(c, &input) bool`. Both helpers are defined in `internal/transport/handlers/validation.go` and return stable error messages ("invalid request body" / "invalid query parameters") rather than exposing raw validation errors. +10. For path params: `c.Param("id")`. diff --git a/backend/docs/streams.md b/backend/docs/streams.md index b2b4c36..8830be7 100644 --- a/backend/docs/streams.md +++ b/backend/docs/streams.md @@ -1,10 +1,12 @@ --- topic: streams -last_verified: 2026-06-15 +last_verified: 2026-06-23 sources: - internal/infrastructure/streams/events.go - internal/infrastructure/streams/producer.go - internal/infrastructure/streams/consumer.go + - internal/usecase/streams.go + - cmd/api/main.go --- # Redis Streams @@ -24,6 +26,45 @@ Streams complement Asynq rather than replace it: All Streams code lives in `internal/infrastructure/streams/`. +## StreamProducer interface + +`internal/usecase/streams.go` defines the interface used by handlers and use cases: + +```go +type StreamProducer interface { + Publish(ctx context.Context, stream string, event any) error + Close() error +} +``` + +`*streams.Producer` implements this interface. `app.StreamProducer` is closed during graceful +shutdown in `cmd/api/main.go` after the HTTP server and worker have stopped. + +## Runtime wiring status + +**Producer:** wired — `app.StreamProducer` is set when `REDIS_URL` is non-empty. It is +passed to `handlers.NewHandler` and is available for use in handlers and use cases. + +**Consumer:** intentionally NOT started at runtime. The consumer infrastructure is fully +implemented but no consumer goroutine is registered in `cmd/api/main.go`. The commented-out +wiring block in `main.go` serves as the canonical example for adding a consumer: + +```go +// streamCtx, streamCancel := context.WithCancel(context.Background()) +// consumer, err := streams.NewConsumer(app.Config.RedisURL, streams.StreamUserCreated, "api", "api-1") +// if err != nil { ... } +// go func() { +// _ = consumer.Run(streamCtx, func(ctx context.Context, data []byte) error { +// var evt streams.UserCreatedEvent +// if err := json.Unmarshal(data, &evt); err != nil { return err } +// payload, _ := json.Marshal(queue.WelcomeEmailPayload{UserID: evt.UserID, Email: evt.Email, Name: evt.Name}) +// return app.Enqueuer.Enqueue(ctx, queue.TypeWelcomeEmail, payload) +// }) +// _ = consumer.Close() +// }() +// // In shutdown: streamCancel() +``` + ## Stream names Stream name constants and event payload structs are defined in @@ -38,6 +79,7 @@ const ( type UserCreatedEvent struct { UserID string `json:"user_id"` Email string `json:"email"` + Name string `json:"name"` } ``` @@ -74,6 +116,7 @@ producer, err := streams.NewProducer(redisURL) err = producer.Publish(ctx, streams.StreamUserCreated, streams.UserCreatedEvent{ UserID: user.ID, Email: user.Email, + Name: user.Name, }) ``` @@ -108,24 +151,31 @@ group name, and a unique consumer name within the group. 7. On `redis.Nil` (timeout with no messages): continues the loop. 8. On other Redis errors: logs and continues. +### Idle-connection hardening + +`NewConsumer` sets `MaxIdleConns=1` and `ConnMaxIdleTime=8s` on the Redis client options. +This proactively recycles idle connections before managed Redis providers (Upstash, Redis +Cloud) kill them at their own idle timeout (~10–60 s). Without this, long-blocking +`XReadGroup` calls surface as `i/o timeout` errors from the pool. + +`Run` also handles `net.Error` timeouts explicitly: it logs a warning and backs off 2 s +before reconnecting, rather than exiting or spinning tightly. + ### Example — registering a consumer goroutine in main.go +See the commented-out wiring block in `cmd/api/main.go` (reproduced in the Runtime wiring +status section above). The key pattern: + ```go -consumer, err := streams.NewConsumer(app.Config.RedisURL, - streams.StreamUserCreated, "api-group", "api-consumer-1") -if err != nil { - // handle -} +streamCtx, streamCancel := context.WithCancel(context.Background()) +consumer, _ := streams.NewConsumer(redisURL, streams.StreamUserCreated, "api", "api-1") go func() { - if err := consumer.Run(ctx, handleUserCreated); err != nil { - slog.Error("streams: consumer error", "err", err) - } + _ = consumer.Run(streamCtx, func(ctx context.Context, data []byte) error { ... }) + _ = consumer.Close() }() +// In shutdown sequence: streamCancel() ``` -Cancel `ctx` (the same child context used for the worker) to stop the consumer during -shutdown, then call `consumer.Close()` to release the connection. - ## Adding a new event type 1. Add a `Stream = "stream:."` constant to @@ -146,7 +196,7 @@ Typical test flow: ```go producer, _ := streams.NewProducer(redisURL) producer.Publish(ctx, streams.StreamUserCreated, streams.UserCreatedEvent{ - UserID: "u1", Email: "a@b.com", + UserID: "u1", Email: "a@b.com", Name: "Alice", }) producer.Close() diff --git a/backend/docs/websocket.md b/backend/docs/websocket.md index 3eeed84..53d0706 100644 --- a/backend/docs/websocket.md +++ b/backend/docs/websocket.md @@ -21,59 +21,80 @@ A `Hub` runs as a long-lived goroutine and fans out messages to all connected cl The `GET /ws` endpoint upgrades HTTP connections; a Firebase ID token is required as a query parameter. -## Message envelope +## Message types -All messages use a typed JSON envelope defined in `internal/infrastructure/ws/message.go`: +`internal/infrastructure/ws/message.go` defines the wire format and inbound handler types: ```go +// Outbound (server → client) and inbound (client → server) wire format. type Envelope struct { Type string `json:"type"` Payload json.RawMessage `json:"payload"` } + +// InboundMessage wraps an Envelope with the originating client ID. +type InboundMessage struct { + ClientID string + Msg Envelope +} + +// InboundHandler processes an inbound WebSocket message. +type InboundHandler func(ctx context.Context, msg InboundMessage) error ``` `Type` is a dot-separated event name (e.g. `"job.completed"`). `Payload` is arbitrary JSON whose shape is determined by `Type`. +The same `Envelope` format is used for both directions — clients send JSON frames in this +shape, and the server broadcasts frames in this shape. + ## Hub `internal/infrastructure/ws/hub.go` ```go -type Hub struct { - clients map[*Client]struct{} - broadcast chan []byte // buffered, capacity 256 - Register chan *Client - Unregister chan *Client -} - func NewHub() *Hub -func (h *Hub) Run(ctx context.Context) // blocking; cancel ctx to stop -func (h *Hub) Publish(msgType string, payload any) error +func (h *Hub) Run(ctx context.Context) // blocking; cancel ctx to stop +func (h *Hub) Publish(msgType string, payload any) error // server → all clients +func (h *Hub) OnMessage(msgType string, handler InboundHandler) // register inbound handler ``` `Run` must be called in its own goroutine and runs until `ctx` is cancelled. -`Publish` marshals `payload` into an `Envelope` and queues it for broadcast — -safe to call from any goroutine (e.g. an Asynq worker in future #18). +`Publish` marshals `payload` into an `Envelope` and queues it for broadcast — safe to call +from any goroutine. +`OnMessage` registers a handler for a specific inbound message type. Safe to call before +or after `Run` starts (protected by `sync.RWMutex`). ### Goroutine model -Each WebSocket connection spawns two goroutines: `ReadPump` and `WritePump` (on `Client`). -The Hub serialises all mutations (register / unregister / broadcast) through a `select` loop -so no locking is needed on its internal `clients` map. +The Hub is now fully bidirectional. Each WebSocket connection spawns `ReadPump` and `WritePump`. +`ReadPump` parses each inbound frame as an `Envelope` and forwards it to `hub.inbound`. +`Run` dispatches inbound messages to registered handlers via a 64-slot semaphore-bounded +goroutine pool so slow handlers do not stall the event loop. ```text + WebSocket conn ──► client.ReadPump goroutine ──► hub.inbound chan + │ + hub.Run goroutine ──► InboundHandler goroutine (≤64 concurrent) + caller goroutine │ hub.Publish(...) ▼ hub.broadcast chan │ hub.Run goroutine ──► client.Send chan ──► client.WritePump goroutine ──► WebSocket conn - client.ReadPump goroutine ──► (discards incoming / handles pings) ``` -Slow clients are dropped: if `client.Send` is full, the Hub closes the channel and -removes the client without blocking the broadcast loop. +Slow clients are dropped: if `client.Send` is full, the Hub closes the channel and removes +the client without blocking the broadcast loop. + +When `hub.inbound` is full, the message is dropped and a warning is logged (non-blocking). +When the semaphore is full (64 in-flight handlers), additional inbound messages are dropped. + +### Lifecycle context + +The `ctx` passed to `hub.Run` is forwarded to each `InboundHandler` goroutine. This means +handlers can respect cancellation and use it for downstream calls (e.g. `db.QueryContext`). ## Client @@ -109,7 +130,8 @@ cannot set `Authorization` headers. 2. If `h.verifier == nil` (development): skips auth — connects immediately. After successful auth, the connection is upgraded and `ReadPump` / `WritePump` are -started in separate goroutines. +started in separate goroutines. `ReadPump` now parses each inbound frame as an `Envelope` +and routes it to the Hub — malformed frames are logged and discarded (the connection stays open). ## Wiring in server.go and main.go @@ -135,9 +157,9 @@ hubCancel() // stop hub after server drains connections The canonical `Handler` struct definition and `NewHandler` signature (including all fields beyond `hub` and `verifier`) are documented in `backend/docs/routing.md`. -## Publishing events from workers (future #18) +## Publishing events from workers -Call `hub.Publish` from any goroutine: +Call `hub.Publish` from any goroutine (Asynq workers, Redis Streams consumers, etc.): ```go hub.Publish("job.completed", map[string]any{ @@ -146,8 +168,19 @@ hub.Publish("job.completed", map[string]any{ }) ``` -When #18 (Asynq + Redis Streams) lands, the Asynq task handlers and Redis Streams -consumers will call this method to push domain events to connected clients. +## Handling inbound messages + +Register an `InboundHandler` before or after calling `hub.Run`: + +```go +hub.OnMessage("ping", func(ctx context.Context, msg ws.InboundMessage) error { + // msg.ClientID is the remote address of the originating client + // msg.Msg.Payload contains the raw JSON payload from the client + return hub.Publish("pong", map[string]any{"client": msg.ClientID}) +}) +``` + +Handlers must be non-blocking or return quickly; the 64-slot semaphore limits concurrency. ## Testing @@ -156,6 +189,7 @@ Unit tests in `internal/infrastructure/ws/hub_test.go` cover: - `TestHub_UnregisterRemovesClient` — Send channel is closed on unregister - `TestHub_ContextCancelClosesSendChannels` — all channels closed on ctx cancel - `TestHub_ConcurrentClientsAndBroadcast` — 10 concurrent clients all receive +- `TestHub_OnMessage` — `InboundHandler` is invoked with correct `InboundMessage` - `TestHub_Publish` — JSON marshalling and delivery Tests inject `*Client` with a nil `conn` and a buffered `Send` channel; the Hub only