From 167306df47d78f73a04f25eeeabce2c9c04d6bc5 Mon Sep 17 00:00:00 2001 From: GRACENOBLE Date: Thu, 25 Jun 2026 14:20:42 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20Day=201=20template=20readiness=20?= =?UTF-8?q?=E2=80=94=20closes=20#44=20#45=20#46=20#47=20#48=20#49?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - CORS allowed origins moved from hardcoded localhost:3000 to CORS_ALLOWED_ORIGINS env var (#44) - Standardized API response envelope: JSON[T], JSONStatus[T], JSONError helpers; all handlers updated (#46) - Request ID middleware: reads/generates X-Request-ID, propagates to logs and response header (#47) Web: - web/.env.example: added BACKEND_URL, NEXT_PUBLIC_BACKEND_URL, SENTRY_ORG, SENTRY_PROJECT (#45) - Error pages: app/not-found.tsx, app/error.tsx, app/global-error.tsx with tests (#48) Mobile: - HTTP client: data/network/ package with OkHttp AuthInterceptor (Firebase token), envelope types, UserApi.getMe() (#49) - BACKEND_URL build config field (defaults to http://10.0.2.2:8080 for emulator) - network_security_config.xml: allow cleartext to 10.0.2.2/localhost in dev builds Docs: - Updated backend/docs: environment.md, routing.md, middleware.md, error-handling.md - Updated web/docs/routing.md with error page conventions - Updated mobile/docs/architecture.md; created mobile/docs/http-client.md --- backend/.env.example | 3 + backend/docs/environment.md | 4 +- backend/docs/error-handling.md | 43 +++- backend/docs/middleware.md | 41 +++- backend/docs/routing.md | 24 +- backend/internal/bootstrap/bootstrap.go | 10 + backend/internal/server/server.go | 2 +- .../transport/handlers/auth_handler.go | 4 +- .../transport/handlers/auth_handler_test.go | 7 +- .../transport/handlers/fcm_handler.go | 12 +- .../transport/handlers/health_handler.go | 4 +- .../transport/handlers/hello_handler.go | 8 +- .../transport/handlers/hello_handler_test.go | 2 +- .../internal/transport/handlers/me_handler.go | 14 +- .../handlers/metrics_handler_test.go | 2 +- .../internal/transport/handlers/response.go | 38 +++ backend/internal/transport/handlers/routes.go | 6 +- .../transport/handlers/storage_handler.go | 6 +- .../handlers/storage_handler_test.go | 10 +- .../internal/transport/handlers/validation.go | 4 +- .../internal/transport/handlers/ws_handler.go | 4 +- .../internal/transport/middleware/logger.go | 1 + .../transport/middleware/request_id.go | 28 +++ mobile/app/build.gradle.kts | 1 + mobile/app/src/main/AndroidManifest.xml | 1 + .../template/data/network/ApiClient.kt | 39 +++ .../template/data/network/ApiResponse.kt | 12 + .../company/template/data/network/UserApi.kt | 37 +++ .../com/company/template/home/HomeScreen.kt | 47 +++- .../main/res/xml/network_security_config.xml | 9 + .../template/data/network/UserApiTest.kt | 66 +++++ mobile/docs/_index.md | 3 +- mobile/docs/architecture.md | 119 +++++++-- mobile/docs/http-client.md | 225 ++++++++++++++++++ web/.env.example | 17 +- web/app/__tests__/error.test.tsx | 43 ++++ web/app/error.tsx | 32 +++ web/app/global-error.tsx | 34 +++ web/app/not-found.tsx | 20 ++ web/docs/routing.md | 20 +- 40 files changed, 910 insertions(+), 92 deletions(-) create mode 100644 backend/internal/transport/handlers/response.go create mode 100644 backend/internal/transport/middleware/request_id.go create mode 100644 mobile/app/src/main/java/com/company/template/data/network/ApiClient.kt create mode 100644 mobile/app/src/main/java/com/company/template/data/network/ApiResponse.kt create mode 100644 mobile/app/src/main/java/com/company/template/data/network/UserApi.kt create mode 100644 mobile/app/src/main/res/xml/network_security_config.xml create mode 100644 mobile/app/src/test/java/com/company/template/data/network/UserApiTest.kt create mode 100644 mobile/docs/http-client.md create mode 100644 web/app/__tests__/error.test.tsx create mode 100644 web/app/error.tsx create mode 100644 web/app/global-error.tsx create mode 100644 web/app/not-found.tsx diff --git a/backend/.env.example b/backend/.env.example index 94f16e5..332adb2 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -10,6 +10,9 @@ BLUEPRINT_DB_SSLMODE=disable REDIS_URL= RATE_LIMIT_RPS=100 # requests/sec per IP (omit or 0 = disabled) RATE_LIMIT_BURST=500 # burst capacity (defaults to RPS×5) +# CORS allowed origins — comma-separated list of allowed web client URLs +# Defaults to http://localhost:3000 if omitted +CORS_ALLOWED_ORIGINS=http://localhost:3000 # Firebase project ID — e.g. my-app-12345 (omit to disable auth) FIREBASE_PROJECT_ID= # Service account key as a single-line JSON string. Get from: Firebase Console → Project Settings → Service Accounts → Generate new private key diff --git a/backend/docs/environment.md b/backend/docs/environment.md index 0d79c34..d7684b1 100644 --- a/backend/docs/environment.md +++ b/backend/docs/environment.md @@ -1,8 +1,9 @@ --- topic: environment -last_verified: 2026-06-23 +last_verified: 2026-06-25 sources: - .env + - .env.example - internal/bootstrap/bootstrap.go - internal/infrastructure/database/postgres/db.go - pkg/firebase/admin.go @@ -47,6 +48,7 @@ This runs on package init before any env var is read — no explicit `godotenv.L | `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. | | `IPAPI_KEY` | `bootstrap.go` | — | ipapi.co API key (optional). Free tier works without a key; supplying one enables higher rate limits. | +| `CORS_ALLOWED_ORIGINS` | `bootstrap.go` | `http://localhost:3000` | Comma-separated list of origins allowed by the CORS middleware. Parsed at startup — each entry is whitespace-trimmed. E.g. `https://app.example.com,https://staging.example.com`. | Variables marked **required** are validated by `bootstrap.validateConfig` at startup — the process exits before attempting a DB connection if any are missing. diff --git a/backend/docs/error-handling.md b/backend/docs/error-handling.md index c0ab667..569159d 100644 --- a/backend/docs/error-handling.md +++ b/backend/docs/error-handling.md @@ -1,15 +1,44 @@ --- topic: error-handling -last_verified: 2026-06-23 +last_verified: 2026-06-25 sources: - internal/infrastructure/database/postgres/health_repository.go - internal/transport/handlers/health_handler.go - internal/transport/handlers/validation.go + - internal/transport/handlers/response.go - cmd/api/main.go --- # Error Handling +## Response envelope + +All handler responses use helpers defined in `internal/transport/handlers/response.go`. Never call `c.JSON` directly in a handler. + +**Success shape:** +```json +{"data": } +``` + +**Error shape:** +```json +{"error": {"code": "snake_case_code", "message": "human readable message"}} +``` + +**Helper signatures:** +```go +// 200 with data wrapped in {"data": ...} +func JSON[T any](c *gin.Context, data T) + +// Any status code with data wrapped in {"data": ...} +func JSONStatus[T any](c *gin.Context, status int, data T) + +// Error response as {"error": {"code": "...", "message": "..."}} +func JSONError(c *gin.Context, status int, code, message string) +``` + +Use `JSON` for standard 200 responses, `JSONStatus` when a non-200 success status is needed (e.g., 201 Created), and `JSONError` for all error responses. + ## General rule Return errors up the call stack. Callers decide how to handle them. Never use `log.Fatal` or `os.Exit` inside `internal/`. @@ -38,17 +67,17 @@ func (r *HealthRepository) Health(ctx context.Context) (domain.HealthStats, erro ``` ## Handler error responses -Handlers call use cases, check errors, and map them to HTTP status codes. The health handler returns 503 when the DB is unreachable: +Handlers call use cases, check errors, and map them to HTTP status codes using the `JSONError` helper. The health handler returns 503 when the DB is unreachable: ```go func (h *Handler) healthHandler(c *gin.Context) { stats, err := h.healthUC.GetHealth(c.Request.Context()) if err != nil { log.Printf("health check failed: %v", err) - c.JSON(http.StatusServiceUnavailable, stats) + JSONStatus(c, http.StatusServiceUnavailable, stats) return } - c.JSON(http.StatusOK, stats) + JSON(c, stats) } ``` @@ -59,13 +88,13 @@ func (h *Handler) getItemHandler(c *gin.Context) { item, err := h.itemUC.GetItem(c.Request.Context(), id) if err != nil { if errors.Is(err, sql.ErrNoRows) { - c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + JSONError(c, http.StatusNotFound, "not_found", "not found") return } - c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) + JSONError(c, http.StatusInternalServerError, "internal_error", "internal error") return } - c.JSON(http.StatusOK, item) + JSON(c, item) } ``` diff --git a/backend/docs/middleware.md b/backend/docs/middleware.md index f268ee2..06038af 100644 --- a/backend/docs/middleware.md +++ b/backend/docs/middleware.md @@ -1,6 +1,6 @@ --- topic: middleware -last_verified: 2026-06-23 +last_verified: 2026-06-25 sources: - internal/transport/middleware/logger.go - internal/transport/middleware/ratelimit.go @@ -8,6 +8,7 @@ sources: - internal/transport/middleware/metrics.go - internal/transport/middleware/local_network.go - internal/transport/middleware/geo.go + - internal/transport/middleware/request_id.go - internal/transport/handlers/routes.go --- @@ -18,15 +19,17 @@ All middleware lives in `internal/transport/middleware/` and follows the Gin `Ha ## Registration order ```go -// 1. Sentry error reporting +// 1. Request ID — must be first so every subsequent middleware can read the ID +r.Use(middleware.RequestID()) +// 2. Sentry error reporting r.Use(middleware.SentryMiddleware(sentryDSN)) -// 2. Recovery + logger (debug: gin.Logger, release: middleware.Logger) +// 3. Recovery + logger (debug: gin.Logger, release: middleware.Logger) r.Use(gin.Recovery(), middleware.Logger()) -// 3. Prometheus metrics collection +// 4. Prometheus metrics collection r.Use(middleware.PrometheusMiddleware()) -// 4. Rate limiter (no-op when RPS <= 0) +// 5. Rate limiter (no-op when RPS <= 0) r.Use(middleware.RateLimit(rps, burst)) -// 5. CORS +// 6. CORS r.Use(cors.New(...)) // Global routes (no auth): @@ -42,9 +45,33 @@ if h.verifier != nil { api.GET("/me", h.MeHandler) ``` +## RequestID + +`RequestID() gin.HandlerFunc` assigns a unique identifier to every request. It is registered as the first middleware in `RegisterRoutes` so all subsequent middleware (including logger and Sentry) have access to the ID. + +```go +const RequestIDKey = "request_id" +const RequestIDHeader = "X-Request-ID" + +func RequestID() gin.HandlerFunc +``` + +Behaviour: +- Reads the `X-Request-ID` request header. If present and non-empty, uses that value (allows callers to propagate their own trace IDs). +- If absent or empty, generates a random 16-byte hex string (`crypto/rand`). +- Stores the ID in the Gin context under `RequestIDKey` via `c.Set`. +- Echoes the ID back in the `X-Request-ID` response header. + +Reading the ID inside a handler or middleware: +```go +requestID := c.GetString(middleware.RequestIDKey) +``` + +The structured `Logger()` middleware appends `"request_id"` to every slog record automatically. + ## Logger -`Logger() gin.HandlerFunc` emits one structured `slog` record per request after `c.Next()` returns. Fields: `status`, `method`, `path`, `latency`, `ip`, and optionally `query` and `errors`. +`Logger() gin.HandlerFunc` emits one structured `slog` record per request after `c.Next()` returns. Fields: `status`, `method`, `path`, `latency`, `ip`, `request_id`, and optionally `query` and `errors`. In debug mode (`ENV` not set to `staging`/`production`) Gin's built-in colorful logger is used instead. diff --git a/backend/docs/routing.md b/backend/docs/routing.md index 84a1e12..db0beb8 100644 --- a/backend/docs/routing.md +++ b/backend/docs/routing.md @@ -1,6 +1,6 @@ --- topic: routing -last_verified: 2026-06-23 +last_verified: 2026-06-25 sources: - internal/transport/handlers/handler.go - internal/transport/handlers/routes.go @@ -9,7 +9,9 @@ sources: - internal/transport/handlers/auth_handler.go - internal/transport/handlers/me_handler.go - internal/transport/handlers/validation.go + - internal/transport/handlers/response.go - internal/transport/middleware/logger.go + - internal/transport/middleware/request_id.go - internal/server/server.go - cmd/api/main.go --- @@ -78,7 +80,7 @@ prometheus.Register(postgres.NewDBStatsCollector(app.DB)) return &http.Server{ Addr: fmt.Sprintf(":%d", app.Config.Port), - Handler: h.RegisterRoutes(app.Config.RateLimitRPS, app.Config.RateLimitBurst, app.Config.SentryDSN), + Handler: h.RegisterRoutes(app.Config.RateLimitRPS, app.Config.RateLimitBurst, app.Config.SentryDSN, app.Config.CORSAllowedOrigins), IdleTimeout: time.Minute, ReadTimeout: 10 * time.Second, WriteTimeout: 30 * time.Second, @@ -88,12 +90,14 @@ return &http.Server{ ## Route registration All routes are registered in `RegisterRoutes()` on `*Handler`, which returns `http.Handler`. `rps` and `burst` come from `bootstrap.Config` (env vars `RATE_LIMIT_RPS` / `RATE_LIMIT_BURST`); pass `rps=0` to disable. +`allowedOrigins` comes from `bootstrap.Config.CORSAllowedOrigins` (env var `CORS_ALLOWED_ORIGINS`); defaults to `["http://localhost:3000"]` when the env var is not set. `h.verifier` (set via `NewHandler`) controls Firebase auth — the verifier is read from the struct, not passed to `RegisterRoutes`; a `nil` verifier skips Firebase auth (development only — see [auth](auth.md)). ```go -func (h *Handler) RegisterRoutes(rps float64, burst int, sentryDSN string) http.Handler { +func (h *Handler) RegisterRoutes(rps float64, burst int, sentryDSN string, allowedOrigins []string) http.Handler { r := gin.New() + r.Use(middleware.RequestID()) r.Use(middleware.SentryMiddleware(sentryDSN)) // Gin's colorful logger locally; structured slog logger in staging/production. @@ -106,7 +110,12 @@ func (h *Handler) RegisterRoutes(rps float64, burst int, sentryDSN string) http. r.Use(middleware.PrometheusMiddleware()) r.Use(middleware.RateLimit(rps, burst)) - r.Use(cors.New(cors.Config{ ... })) + r.Use(cors.New(cors.Config{ + AllowOrigins: allowedOrigins, + AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"}, + AllowHeaders: []string{"Accept", "Authorization", "Content-Type"}, + AllowCredentials: true, + })) r.GET("/", h.HelloWorldHandler) r.GET("/health", h.HealthHandler) @@ -156,21 +165,22 @@ func (h *Handler) RegisterRoutes(rps float64, burst int, sentryDSN string) http. ## Handler pattern All handlers are methods on `*Handler`. Always use `*gin.Context`. +Use `JSON`, `JSONStatus`, and `JSONError` from `internal/transport/handlers/response.go` — do not call `c.JSON` directly in handlers (see [error-handling](error-handling.md) for the envelope shape). ```go func (h *Handler) myHandler(c *gin.Context) { result, err := h.someUC.DoSomething(c.Request.Context(), ...) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) + JSONError(c, http.StatusInternalServerError, "internal_error", "internal error") return } - c.JSON(http.StatusOK, result) + JSON(c, result) } ``` ## CORS configuration Pre-configured in `RegisterRoutes()` via `github.com/gin-contrib/cors`. -Current allowed origin: `http://localhost:3000`. +Allowed origins come from `allowedOrigins []string` (4th parameter), sourced from `bootstrap.Config.CORSAllowedOrigins` — set via the `CORS_ALLOWED_ORIGINS` env var (comma-separated, defaults to `http://localhost:3000`). Allowed methods: GET, POST, PUT, DELETE, OPTIONS, PATCH. `AllowCredentials: true` — cookies and auth headers pass through. diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index e8baff6..9d8ca01 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -72,6 +72,7 @@ type Config struct { R2Bucket string R2PublicURL string IPAPIKey string + CORSAllowedOrigins []string } // ConfigError is returned when required configuration is absent or invalid. @@ -230,6 +231,14 @@ func loadConfig() Config { burst = int(rps) * 5 } + corsOrigins := []string{"http://localhost:3000"} + if raw := os.Getenv("CORS_ALLOWED_ORIGINS"); raw != "" { + corsOrigins = strings.Split(raw, ",") + for i, o := range corsOrigins { + corsOrigins[i] = strings.TrimSpace(o) + } + } + return Config{ Port: port, Env: os.Getenv("ENV"), @@ -249,6 +258,7 @@ func loadConfig() Config { R2Bucket: os.Getenv("R2_BUCKET"), R2PublicURL: os.Getenv("R2_PUBLIC_URL"), IPAPIKey: os.Getenv("IPAPI_KEY"), + CORSAllowedOrigins: corsOrigins, DB: postgres.DBConfig{ Host: os.Getenv("BLUEPRINT_DB_HOST"), Port: os.Getenv("BLUEPRINT_DB_PORT"), diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 7bfa770..ba15370 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -73,7 +73,7 @@ func NewServer(app *bootstrap.App, hub *ws.Hub) (*http.Server, error) { return &http.Server{ Addr: fmt.Sprintf(":%d", app.Config.Port), - Handler: h.RegisterRoutes(app.Config.RateLimitRPS, app.Config.RateLimitBurst, app.Config.SentryDSN), + Handler: h.RegisterRoutes(app.Config.RateLimitRPS, app.Config.RateLimitBurst, app.Config.SentryDSN, app.Config.CORSAllowedOrigins), IdleTimeout: time.Minute, ReadTimeout: 10 * time.Second, WriteTimeout: 30 * time.Second, diff --git a/backend/internal/transport/handlers/auth_handler.go b/backend/internal/transport/handlers/auth_handler.go index df6a9a1..83b5bba 100644 --- a/backend/internal/transport/handlers/auth_handler.go +++ b/backend/internal/transport/handlers/auth_handler.go @@ -21,8 +21,8 @@ func (h *Handler) MeHandler(c *gin.Context) { val, _ := c.Get(middleware.FirebaseClaimsKey) token, ok := val.(*usecase.FirebaseToken) if !ok { - c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + JSONError(c, http.StatusUnauthorized, "unauthorized", "missing or invalid token") return } - c.JSON(http.StatusOK, token) + JSON(c, token) } diff --git a/backend/internal/transport/handlers/auth_handler_test.go b/backend/internal/transport/handlers/auth_handler_test.go index 9e07a55..5d72be3 100644 --- a/backend/internal/transport/handlers/auth_handler_test.go +++ b/backend/internal/transport/handlers/auth_handler_test.go @@ -30,10 +30,13 @@ func TestMeHandler_WithClaims(t *testing.T) { t.Fatalf("expected 200, got %d", w.Code) } - var got usecase.FirebaseToken - if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + var resp struct { + Data usecase.FirebaseToken `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal body: %v", err) } + got := resp.Data if got.UID != want.UID || got.Email != want.Email || got.Name != want.Name { t.Errorf("response mismatch: got %+v, want %+v", got, *want) } diff --git a/backend/internal/transport/handlers/fcm_handler.go b/backend/internal/transport/handlers/fcm_handler.go index ff17587..895d72d 100644 --- a/backend/internal/transport/handlers/fcm_handler.go +++ b/backend/internal/transport/handlers/fcm_handler.go @@ -38,7 +38,7 @@ func (h *Handler) RegisterFCMToken(c *gin.Context) { val, _ := c.Get(middleware.FirebaseClaimsKey) claims, ok := val.(*usecase.FirebaseToken) if !ok { - c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + JSONError(c, http.StatusUnauthorized, "unauthorized", "missing or invalid token") return } @@ -48,7 +48,7 @@ func (h *Handler) RegisterFCMToken(c *gin.Context) { } if err := h.fcmTokenRepo.SaveToken(c.Request.Context(), claims.UID, req.Token, req.Platform); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save token"}) + JSONError(c, http.StatusInternalServerError, "internal_error", "failed to save token") return } @@ -62,7 +62,7 @@ func (h *Handler) RegisterFCMToken(c *gin.Context) { } } - c.JSON(http.StatusOK, gin.H{"message": "token registered"}) + JSON(c, gin.H{"message": "token registered"}) } // UnregisterFCMToken removes an FCM device token, typically called on logout. @@ -83,7 +83,7 @@ func (h *Handler) UnregisterFCMToken(c *gin.Context) { val, _ := c.Get(middleware.FirebaseClaimsKey) claims, ok := val.(*usecase.FirebaseToken) if !ok { - c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + JSONError(c, http.StatusUnauthorized, "unauthorized", "missing or invalid token") return } @@ -93,9 +93,9 @@ func (h *Handler) UnregisterFCMToken(c *gin.Context) { } if err := h.fcmTokenRepo.DeleteToken(c.Request.Context(), claims.UID, req.Token); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove token"}) + JSONError(c, http.StatusInternalServerError, "internal_error", "failed to remove token") return } - c.JSON(http.StatusOK, gin.H{"message": "token unregistered"}) + JSON(c, gin.H{"message": "token unregistered"}) } diff --git a/backend/internal/transport/handlers/health_handler.go b/backend/internal/transport/handlers/health_handler.go index 9e64335..3216a47 100644 --- a/backend/internal/transport/handlers/health_handler.go +++ b/backend/internal/transport/handlers/health_handler.go @@ -17,8 +17,8 @@ func (h *Handler) HealthHandler(c *gin.Context) { stats, err := h.healthUC.GetHealth(c.Request.Context()) if err != nil { slog.Warn("health check failed", "error", err) - c.JSON(http.StatusServiceUnavailable, stats) + JSONStatus(c, http.StatusServiceUnavailable, stats) return } - c.JSON(http.StatusOK, stats) + JSONStatus(c, http.StatusOK, stats) } diff --git a/backend/internal/transport/handlers/hello_handler.go b/backend/internal/transport/handlers/hello_handler.go index b0a471d..5ac7100 100644 --- a/backend/internal/transport/handlers/hello_handler.go +++ b/backend/internal/transport/handlers/hello_handler.go @@ -1,10 +1,6 @@ package handlers -import ( - "net/http" - - "github.com/gin-gonic/gin" -) +import "github.com/gin-gonic/gin" // @Summary Hello World // @Tags general @@ -12,5 +8,5 @@ import ( // @Success 200 {object} map[string]string // @Router / [get] func (h *Handler) HelloWorldHandler(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"message": "Hello World"}) + JSON(c, gin.H{"message": "Hello World"}) } diff --git a/backend/internal/transport/handlers/hello_handler_test.go b/backend/internal/transport/handlers/hello_handler_test.go index 1e5dad2..8763e20 100644 --- a/backend/internal/transport/handlers/hello_handler_test.go +++ b/backend/internal/transport/handlers/hello_handler_test.go @@ -25,7 +25,7 @@ func TestHelloWorldHandler(t *testing.T) { t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK) } - expected := `{"message":"Hello World"}` + expected := `{"data":{"message":"Hello World"}}` if rr.Body.String() != expected { t.Errorf("handler returned unexpected body: got %v want %v", rr.Body.String(), expected) } diff --git a/backend/internal/transport/handlers/me_handler.go b/backend/internal/transport/handlers/me_handler.go index d401024..13cb1a6 100644 --- a/backend/internal/transport/handlers/me_handler.go +++ b/backend/internal/transport/handlers/me_handler.go @@ -36,12 +36,12 @@ func (h *Handler) UpdateMeHandler(c *gin.Context) { raw, exists := c.Get(middleware.FirebaseClaimsKey) if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + JSONError(c, http.StatusUnauthorized, "unauthorized", "missing or invalid token") return } claims, ok := raw.(*usecase.FirebaseToken) if !ok { - c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + JSONError(c, http.StatusUnauthorized, "unauthorized", "missing or invalid token") return } @@ -53,10 +53,10 @@ func (h *Handler) UpdateMeHandler(c *gin.Context) { } updated, err := h.userRepo.Upsert(c.Request.Context(), u) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update profile"}) + JSONError(c, http.StatusInternalServerError, "internal_error", "failed to update profile") return } - c.JSON(http.StatusOK, updated) + JSON(c, updated) } // DeleteMeHandler godoc @@ -73,17 +73,17 @@ func (h *Handler) UpdateMeHandler(c *gin.Context) { func (h *Handler) DeleteMeHandler(c *gin.Context) { raw, exists := c.Get(middleware.FirebaseClaimsKey) if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + JSONError(c, http.StatusUnauthorized, "unauthorized", "missing or invalid token") return } claims, ok := raw.(*usecase.FirebaseToken) if !ok { - c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + JSONError(c, http.StatusUnauthorized, "unauthorized", "missing or invalid token") return } if err := h.userRepo.DeleteByFirebaseUID(c.Request.Context(), claims.UID); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete account"}) + JSONError(c, http.StatusInternalServerError, "internal_error", "failed to delete account") return } c.Status(http.StatusNoContent) diff --git a/backend/internal/transport/handlers/metrics_handler_test.go b/backend/internal/transport/handlers/metrics_handler_test.go index 1c36423..d1c22e2 100644 --- a/backend/internal/transport/handlers/metrics_handler_test.go +++ b/backend/internal/transport/handlers/metrics_handler_test.go @@ -9,7 +9,7 @@ import ( func TestMetricsEndpoint_Returns200(t *testing.T) { h := &Handler{} - handler := h.RegisterRoutes(0, 0, "") + handler := h.RegisterRoutes(0, 0, "", []string{"http://localhost:3000"}) req := httptest.NewRequest(http.MethodGet, "/metrics", nil) w := httptest.NewRecorder() diff --git a/backend/internal/transport/handlers/response.go b/backend/internal/transport/handlers/response.go new file mode 100644 index 0000000..dc63f24 --- /dev/null +++ b/backend/internal/transport/handlers/response.go @@ -0,0 +1,38 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// envelope wraps all successful responses as {"data": ...}. +type envelope[T any] struct { + Data T `json:"data"` +} + +// errDetail is the inner object of all error responses. +type errDetail struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// errBody is the shape of all error responses: {"error": {"code": "...", "message": "..."}}. +type errBody struct { + Error errDetail `json:"error"` +} + +// JSON writes a 200 response with the data wrapped in {"data": ...}. +func JSON[T any](c *gin.Context, data T) { + c.JSON(http.StatusOK, envelope[T]{Data: data}) +} + +// JSONStatus writes any status code with the data wrapped in {"data": ...}. +func JSONStatus[T any](c *gin.Context, status int, data T) { + c.JSON(status, envelope[T]{Data: data}) +} + +// JSONError writes an error response as {"error": {"code": "...", "message": "..."}}. +func JSONError(c *gin.Context, status int, code, message string) { + c.JSON(status, errBody{Error: errDetail{Code: code, Message: message}}) +} diff --git a/backend/internal/transport/handlers/routes.go b/backend/internal/transport/handlers/routes.go index 8398b40..07b943f 100644 --- a/backend/internal/transport/handlers/routes.go +++ b/backend/internal/transport/handlers/routes.go @@ -16,10 +16,12 @@ import ( // RegisterRoutes creates the Gin engine, applies middleware, and registers all routes. // rps and burst configure IP-based rate limiting; pass rps<=0 to disable. // sentryDSN enables Sentry error tracking; pass empty string to disable. +// allowedOrigins is the list of CORS allowed origins; defaults to localhost:3000 when empty. // Firebase auth is read from h.verifier; nil disables auth (dev only). -func (h *Handler) RegisterRoutes(rps float64, burst int, sentryDSN string) http.Handler { +func (h *Handler) RegisterRoutes(rps float64, burst int, sentryDSN string, allowedOrigins []string) http.Handler { r := gin.New() + r.Use(middleware.RequestID()) r.Use(middleware.SentryMiddleware(sentryDSN)) // Use Gin's colorful logger locally; structured slog logger in staging/production. @@ -33,7 +35,7 @@ func (h *Handler) RegisterRoutes(rps float64, burst int, sentryDSN string) http. r.Use(middleware.RateLimit(rps, burst)) r.Use(cors.New(cors.Config{ - AllowOrigins: []string{"http://localhost:3000"}, + AllowOrigins: allowedOrigins, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"}, AllowHeaders: []string{"Accept", "Authorization", "Content-Type"}, AllowCredentials: true, diff --git a/backend/internal/transport/handlers/storage_handler.go b/backend/internal/transport/handlers/storage_handler.go index dac5f07..0441b97 100644 --- a/backend/internal/transport/handlers/storage_handler.go +++ b/backend/internal/transport/handlers/storage_handler.go @@ -37,11 +37,11 @@ func (h *Handler) PresignHandler(c *gin.Context) { uploadURL, err := h.storageService.PresignUpload(c.Request.Context(), req.Filename, req.ContentType, 15*time.Minute) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate upload URL"}) + JSONError(c, http.StatusInternalServerError, "internal_error", "failed to generate upload URL") return } - c.JSON(http.StatusOK, presignResponse{ + JSON(c, presignResponse{ UploadURL: uploadURL, PublicURL: h.storageService.PublicURL(req.Filename), }) @@ -59,7 +59,7 @@ func (h *Handler) DeleteObjectHandler(c *gin.Context) { key := c.Param("key") if err := h.storageService.Delete(c.Request.Context(), key); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete object"}) + JSONError(c, http.StatusInternalServerError, "internal_error", "failed to delete object") return } diff --git a/backend/internal/transport/handlers/storage_handler_test.go b/backend/internal/transport/handlers/storage_handler_test.go index b88529c..6d66f45 100644 --- a/backend/internal/transport/handlers/storage_handler_test.go +++ b/backend/internal/transport/handlers/storage_handler_test.go @@ -75,14 +75,16 @@ func TestPresignHandler_HappyPath(t *testing.T) { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } - var resp presignResponse - if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + var envelope struct { + Data presignResponse `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &envelope); err != nil { t.Fatalf("unmarshal response: %v", err) } - if resp.UploadURL == "" { + if envelope.Data.UploadURL == "" { t.Error("expected non-empty upload_url") } - if resp.PublicURL == "" { + if envelope.Data.PublicURL == "" { t.Error("expected non-empty public_url") } } diff --git a/backend/internal/transport/handlers/validation.go b/backend/internal/transport/handlers/validation.go index 4a425c6..5b15979 100644 --- a/backend/internal/transport/handlers/validation.go +++ b/backend/internal/transport/handlers/validation.go @@ -9,7 +9,7 @@ import ( // bindJSON binds and validates the request body. Writes 400 on failure and returns false. func bindJSON(c *gin.Context, dst any) bool { if err := c.ShouldBindJSON(dst); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + JSONError(c, http.StatusBadRequest, "bad_request", "invalid request body") return false } return true @@ -18,7 +18,7 @@ func bindJSON(c *gin.Context, dst any) bool { // bindQuery binds and validates query params. Writes 400 on failure and returns false. func bindQuery(c *gin.Context, dst any) bool { if err := c.ShouldBindQuery(dst); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid query parameters"}) + JSONError(c, http.StatusBadRequest, "bad_request", "invalid query parameters") return false } return true diff --git a/backend/internal/transport/handlers/ws_handler.go b/backend/internal/transport/handlers/ws_handler.go index 0913dfe..d531eea 100644 --- a/backend/internal/transport/handlers/ws_handler.go +++ b/backend/internal/transport/handlers/ws_handler.go @@ -41,11 +41,11 @@ func (h *Handler) WsHandler(c *gin.Context) { if h.verifier != nil { token := c.Query("token") if token == "" { - c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"}) + JSONError(c, http.StatusUnauthorized, "unauthorized", "missing token") return } if _, err := h.verifier.VerifyIDToken(c.Request.Context(), token); err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"}) + JSONError(c, http.StatusUnauthorized, "unauthorized", "invalid or expired token") return } } diff --git a/backend/internal/transport/middleware/logger.go b/backend/internal/transport/middleware/logger.go index fb8083f..647d1e7 100644 --- a/backend/internal/transport/middleware/logger.go +++ b/backend/internal/transport/middleware/logger.go @@ -26,6 +26,7 @@ func Logger() gin.HandlerFunc { "path", path, "latency", latency, "ip", c.ClientIP(), + "request_id", c.GetString(RequestIDKey), } if query != "" { attrs = append(attrs, "query", query) diff --git a/backend/internal/transport/middleware/request_id.go b/backend/internal/transport/middleware/request_id.go new file mode 100644 index 0000000..fb1e903 --- /dev/null +++ b/backend/internal/transport/middleware/request_id.go @@ -0,0 +1,28 @@ +package middleware + +import ( + "crypto/rand" + "encoding/hex" + + "github.com/gin-gonic/gin" +) + +const RequestIDKey = "request_id" +const RequestIDHeader = "X-Request-ID" + +// RequestID reads X-Request-ID from the incoming request. If absent or empty, +// it generates a random 16-byte hex ID. The ID is stored on the Gin context +// under RequestIDKey and echoed back in the X-Request-ID response header. +func RequestID() gin.HandlerFunc { + return func(c *gin.Context) { + id := c.GetHeader(RequestIDHeader) + if id == "" { + b := make([]byte, 16) + _, _ = rand.Read(b) + id = hex.EncodeToString(b) + } + c.Set(RequestIDKey, id) + c.Header(RequestIDHeader, id) + c.Next() + } +} diff --git a/mobile/app/build.gradle.kts b/mobile/app/build.gradle.kts index df0eb1d..bc95f2f 100644 --- a/mobile/app/build.gradle.kts +++ b/mobile/app/build.gradle.kts @@ -29,6 +29,7 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" buildConfigField("String", "SENTRY_DSN", "\"${localProps.getProperty("SENTRY_DSN", "")}\"") buildConfigField("String", "GOOGLE_WEB_CLIENT_ID", "\"${localProps.getProperty("GOOGLE_WEB_CLIENT_ID", "")}\"") + buildConfigField("String", "BACKEND_URL", "\"${localProps.getProperty("BACKEND_URL", "http://10.0.2.2:8080")}\"") } buildTypes { diff --git a/mobile/app/src/main/AndroidManifest.xml b/mobile/app/src/main/AndroidManifest.xml index fd90205..c504a35 100644 --- a/mobile/app/src/main/AndroidManifest.xml +++ b/mobile/app/src/main/AndroidManifest.xml @@ -13,6 +13,7 @@ android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" + android:networkSecurityConfig="@xml/network_security_config" android:supportsRtl="true" android:theme="@style/Theme.Template"> diff --git a/mobile/app/src/main/java/com/company/template/data/network/ApiClient.kt b/mobile/app/src/main/java/com/company/template/data/network/ApiClient.kt new file mode 100644 index 0000000..3495a05 --- /dev/null +++ b/mobile/app/src/main/java/com/company/template/data/network/ApiClient.kt @@ -0,0 +1,39 @@ +package com.company.template.data.network + +import com.google.firebase.auth.FirebaseAuth +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Response +import java.util.concurrent.TimeUnit + +object ApiClient { + // Interceptor that attaches the Firebase ID token on every request. + // intercept() runs on a background OkHttp dispatcher thread, so + // getIdToken().result (synchronous) is safe — do NOT use await() here. + private class AuthInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val token = runCatching { + // getIdToken(false) returns the cached token if still valid + FirebaseAuth.getInstance().currentUser + ?.getIdToken(false) + ?.result + ?.token + }.getOrNull() + + val request = if (token != null) { + chain.request().newBuilder() + .header("Authorization", "Bearer $token") + .build() + } else { + chain.request() + } + return chain.proceed(request) + } + } + + val httpClient: OkHttpClient = OkHttpClient.Builder() + .addInterceptor(AuthInterceptor()) + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() +} diff --git a/mobile/app/src/main/java/com/company/template/data/network/ApiResponse.kt b/mobile/app/src/main/java/com/company/template/data/network/ApiResponse.kt new file mode 100644 index 0000000..beaed8b --- /dev/null +++ b/mobile/app/src/main/java/com/company/template/data/network/ApiResponse.kt @@ -0,0 +1,12 @@ +package com.company.template.data.network + +import kotlinx.serialization.Serializable + +@Serializable +data class ApiResponse(val data: T) + +@Serializable +data class ApiErrorDetail(val code: String, val message: String) + +@Serializable +data class ApiErrorResponse(val error: ApiErrorDetail) diff --git a/mobile/app/src/main/java/com/company/template/data/network/UserApi.kt b/mobile/app/src/main/java/com/company/template/data/network/UserApi.kt new file mode 100644 index 0000000..c34968a --- /dev/null +++ b/mobile/app/src/main/java/com/company/template/data/network/UserApi.kt @@ -0,0 +1,37 @@ +package com.company.template.data.network + +import com.company.template.BuildConfig +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import okhttp3.OkHttpClient +import okhttp3.Request + +@Serializable +data class UserProfile( + val uid: String, + val email: String? = null, + val displayName: String? = null, +) + +object UserApi { + private val json = Json { ignoreUnknownKeys = true } + + suspend fun getMe( + baseUrl: String = BuildConfig.BACKEND_URL, + client: OkHttpClient = ApiClient.httpClient, + ): Result = runCatching { + val request = Request.Builder() + .url("$baseUrl/api/v1/me") + .get() + .build() + + client.newCall(request).execute().use { response -> + val body = response.body?.string() ?: error("empty body") + if (!response.isSuccessful) { + val err = json.decodeFromString(body) + error(err.error.message) + } + json.decodeFromString>(body).data + } + } +} diff --git a/mobile/app/src/main/java/com/company/template/home/HomeScreen.kt b/mobile/app/src/main/java/com/company/template/home/HomeScreen.kt index 7bb6967..aa7ec52 100644 --- a/mobile/app/src/main/java/com/company/template/home/HomeScreen.kt +++ b/mobile/app/src/main/java/com/company/template/home/HomeScreen.kt @@ -12,11 +12,20 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.company.template.data.network.UserApi +import com.company.template.data.network.UserProfile import com.company.template.ui.theme.TemplateTheme +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext @Composable fun HomeScreen( @@ -24,6 +33,16 @@ fun HomeScreen( onSignOut: () -> Unit, modifier: Modifier = Modifier ) { + var profile by remember { mutableStateOf(null) } + var profileError by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + val result = withContext(Dispatchers.IO) { UserApi.getMe() } + result + .onSuccess { profile = it } + .onFailure { profileError = it.message } + } + Column( modifier = modifier .fillMaxSize() @@ -43,8 +62,34 @@ fun HomeScreen( style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface ) - Spacer(modifier = Modifier.height(48.dp)) + Spacer(modifier = Modifier.height(8.dp)) + } + profile?.let { p -> + p.displayName?.let { name -> + Text( + text = name, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + p.email?.let { email -> + Text( + text = email, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(modifier = Modifier.height(8.dp)) + } + profileError?.let { err -> + Text( + text = "Profile error: $err", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(8.dp)) } + Spacer(modifier = Modifier.height(40.dp)) Button( onClick = onSignOut, colors = ButtonDefaults.buttonColors( diff --git a/mobile/app/src/main/res/xml/network_security_config.xml b/mobile/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..338c1fc --- /dev/null +++ b/mobile/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,9 @@ + + + + + 10.0.2.2 + localhost + + diff --git a/mobile/app/src/test/java/com/company/template/data/network/UserApiTest.kt b/mobile/app/src/test/java/com/company/template/data/network/UserApiTest.kt new file mode 100644 index 0000000..44b8bd8 --- /dev/null +++ b/mobile/app/src/test/java/com/company/template/data/network/UserApiTest.kt @@ -0,0 +1,66 @@ +package com.company.template.data.network + +import kotlinx.coroutines.test.runTest +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class UserApiTest { + + private lateinit var server: MockWebServer + + // A plain OkHttpClient with no auth interceptor — tests don't need Firebase + private val testClient = OkHttpClient() + + @Before + fun setUp() { + server = MockWebServer() + server.start() + } + + @After + fun tearDown() { + server.shutdown() + } + + @Test + fun `getMe returns UserProfile on successful response`() = runTest { + server.enqueue( + MockResponse() + .setResponseCode(200) + .setBody("""{"data":{"uid":"u1","email":"a@b.com"}}""") + ) + + val result = UserApi.getMe( + baseUrl = server.url("/").toString().trimEnd('/'), + client = testClient, + ) + + assertTrue(result.isSuccess) + val profile = result.getOrThrow() + assertEquals("u1", profile.uid) + assertEquals("a@b.com", profile.email) + } + + @Test + fun `getMe returns failure with backend message on error response`() = runTest { + server.enqueue( + MockResponse() + .setResponseCode(401) + .setBody("""{"error":{"code":"UNAUTHENTICATED","message":"no token"}}""") + ) + + val result = UserApi.getMe( + baseUrl = server.url("/").toString().trimEnd('/'), + client = testClient, + ) + + assertTrue(result.isFailure) + assertEquals("no token", result.exceptionOrNull()?.message) + } +} diff --git a/mobile/docs/_index.md b/mobile/docs/_index.md index 3984ed7..2b87492 100644 --- a/mobile/docs/_index.md +++ b/mobile/docs/_index.md @@ -1,6 +1,6 @@ --- topic: index -last_verified: 2026-06-16 +last_verified: 2026-06-25 --- # Mobile docs index @@ -15,3 +15,4 @@ Topic-based documentation for the Android app. Each file is kept in sync with th | Observability (Sentry error tracking) | `observability.md` | `gradle/libs.versions.toml`, `app/build.gradle.kts`, `app/src/main/java/com/company/template/MainActivity.kt` | | Firebase Cloud Messaging — service, token registration, background notifications | `fcm.md` | `app/src/main/java/com/company/template/fcm/MyFirebaseMessagingService.kt`, `app/src/main/java/com/company/template/fcm/FcmRegistrationPayload.kt`, `app/src/main/AndroidManifest.xml`, `gradle/libs.versions.toml` | | Object storage (Cloudflare R2) — UploadRepository interface, R2UploadRepository, presign + PUT flow | `storage.md` | `app/src/main/java/com/company/template/storage/UploadRepository.kt` | +| HTTP client and API layer — ApiClient, envelope types, UserApi, MockWebServer testing, BACKEND_URL | `http-client.md` | `app/src/main/java/com/company/template/data/network/ApiClient.kt`, `ApiResponse.kt`, `UserApi.kt`, `app/src/test/java/com/company/template/data/network/UserApiTest.kt` | diff --git a/mobile/docs/architecture.md b/mobile/docs/architecture.md index 02180c9..4be10c0 100644 --- a/mobile/docs/architecture.md +++ b/mobile/docs/architecture.md @@ -1,10 +1,13 @@ --- topic: Activity and Compose architecture -last_verified: 2026-06-14 +last_verified: 2026-06-25 sources: - app/src/main/java/com/company/template/MainActivity.kt - app/build.gradle.kts - gradle/libs.versions.toml + - app/src/main/java/com/company/template/data/network/ApiClient.kt + - app/src/main/java/com/company/template/data/network/ApiResponse.kt + - app/src/main/java/com/company/template/data/network/UserApi.kt --- # Activity and Compose architecture @@ -15,14 +18,42 @@ sources: ```kotlin class MainActivity : ComponentActivity() { + + @SuppressLint("InvalidFragmentVersionForActivityResult") + private val requestNotificationPermission = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* no-op */ } + + private val authRepository by lazy { FirebaseAuthRepository(applicationContext) } + private val onboardingRepository by lazy { DataStoreOnboardingRepository(applicationContext) } + + private val authViewModel: AuthViewModel by viewModels { + AuthViewModel.factory(authRepository) + } + private val appViewModel: AppViewModel by viewModels { + AppViewModel.factory(authRepository, onboardingRepository) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) + } + if (shouldInitSentry(BuildConfig.SENTRY_DSN)) { + SentryAndroid.init(this) { options -> + options.dsn = BuildConfig.SENTRY_DSN + options.tracesSampleRate = 1.0 + } + } + FirebaseMessaging.getInstance().token.addOnSuccessListener { token -> + Log.d("FCM_TOKEN", token) + } enableEdgeToEdge() setContent { TemplateTheme { Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - Greeting( - name = "Android", + AppNavGraph( + appViewModel = appViewModel, + authViewModel = authViewModel, modifier = Modifier.padding(innerPadding) ) } @@ -33,13 +64,26 @@ class MainActivity : ComponentActivity() { ``` Key calls: +- `requestNotificationPermission` — registered at construction time; launched on API 33+ (TIRAMISU) to request `POST_NOTIFICATIONS` at runtime. +- `shouldInitSentry(dsn)` — top-level function (unit-testable on JVM) that returns `true` when `BuildConfig.SENTRY_DSN` is non-blank; Sentry is only initialised when a DSN is present. +- `FirebaseMessaging.getInstance().token` — fetches the FCM registration token and logs it at startup. - `enableEdgeToEdge()` — called before `setContent`; allows content to draw behind system bars. - `setContent { }` — replaces the XML layout system; the lambda is the Compose UI root. - `TemplateTheme { }` — applied once here; all screens inherit the theme automatically. +- `AppNavGraph` — the top-level navigation graph; receives `appViewModel` and `authViewModel` as parameters. + +## ViewModels wired in MainActivity + +`AuthViewModel` and `AppViewModel` are both scoped to the Activity via `by viewModels { }` with custom factories: + +- `AuthViewModel.factory(authRepository)` — owns Firebase auth state. +- `AppViewModel.factory(authRepository, onboardingRepository)` — drives the initial navigation decision (onboarding vs. home). + +Both repositories are instantiated lazily with `applicationContext` to avoid Activity leaks. ## Single-Activity pattern -There is no `Fragment` stack. All navigation between screens happens inside the Compose composition (via Navigation Compose when added). Do not create additional Activities or Fragments. +There is no `Fragment` stack. All navigation between screens happens inside the Compose composition via Navigation Compose. Do not create additional Activities or Fragments. ## Lifecycle @@ -47,39 +91,66 @@ There is no `Fragment` stack. All navigation between screens happens inside the - Use `viewModel()` (from `lifecycle-viewmodel-compose`) to scope ViewModels to the Activity or a nav destination. - Collect `StateFlow` / `Flow` from ViewModels using `collectAsStateWithLifecycle()` (from `lifecycle-runtime-compose`) — not `collectAsState()`, which does not respect lifecycle. +## Build configuration + +- `compileSdk` 36 (minor API level 1), `minSdk` 24, `targetSdk` 36 +- Source and target compatibility: Java 11 +- `buildFeatures { compose = true; buildConfig = true }` — enables the Compose compiler and the `BuildConfig` class generation +- Kotlin plugin: `org.jetbrains.kotlin.plugin.compose` and `org.jetbrains.kotlin.plugin.serialization` +- Google Services plugin: `com.google.gms.google-services` + +### buildConfigField entries + +Both fields are read from `local.properties` at build time. They default to an empty string if the key is absent: + +| Field | Type | Source key | +|---|---|---| +| `SENTRY_DSN` | `String` | `local.properties: SENTRY_DSN` | +| `GOOGLE_WEB_CLIENT_ID` | `String` | `local.properties: GOOGLE_WEB_CLIENT_ID` | + +Release build validation: assembling or bundling a release variant fails fast if `GOOGLE_WEB_CLIENT_ID` is empty in `local.properties`. + +| Field | Type | Source key | Default | +|---|---|---|---| +| `BACKEND_URL` | `String` | `local.properties: BACKEND_URL` | `http://10.0.2.2:8080` | + +`http://10.0.2.2:8080` is the Android emulator alias for host `localhost`. Override in `local.properties` for physical devices or staging environments. + ## Dependency versions All versions are declared in `gradle/libs.versions.toml`. Current versions: -| Library | Version | +| Library / group | Version | |---|---| | Kotlin | 2.2.10 | | AGP | 9.2.1 | +| Google Services plugin | 4.4.2 | | Compose BOM | 2026.02.01 | | `androidx.core:core-ktx` | 1.10.1 | | `androidx.lifecycle:lifecycle-runtime-ktx` | 2.6.1 | +| `androidx.lifecycle:lifecycle-viewmodel-compose` | 2.9.0 | +| `androidx.lifecycle:lifecycle-runtime-compose` | 2.9.0 | | `androidx.activity:activity-compose` | 1.8.0 | +| `androidx.navigation:navigation-compose` | 2.9.0 | +| `androidx.credentials:credentials` | 1.5.0 | +| `androidx.credentials:credentials-play-services-auth` | 1.5.0 | +| `androidx.datastore:datastore-preferences` | 1.1.7 | +| `com.google.android.libraries.identity.googleid:googleid` | 1.1.1 | +| `com.google.firebase:firebase-bom` | 33.7.0 | +| `io.sentry:sentry-android` | 8.14.0 | +| `com.squareup.okhttp3:okhttp` | 4.12.0 | +| `org.jetbrains.kotlinx:kotlinx-coroutines-android` | 1.10.2 | +| `org.jetbrains.kotlinx:kotlinx-serialization-json` | 1.8.1 | +| `io.coil-kt:coil-compose` | 2.7.0 | -Compose library versions (ui, material3, etc.) are managed by the BOM — do not pin them individually. - -## Build configuration +Compose library versions (ui, material3, etc.) are managed by the BOM — do not pin them individually. Firebase library versions (firebase-messaging-ktx, firebase-auth-ktx, etc.) are managed by the Firebase BOM — do not pin them individually. -- `compileSdk` 36, `minSdk` 24, `targetSdk` 36 -- Source and target compatibility: Java 11 -- `buildFeatures { compose = true }` — enables the Compose compiler -- Kotlin plugin: `org.jetbrains.kotlin.plugin.compose` (separate from the language plugin; required for Compose) +## Data layer -## Adding a ViewModel (when needed) +The `data/network/` package contains the HTTP client and all API call functions. Key types: -1. Add `lifecycle-viewmodel-compose` to `libs.versions.toml` and `app/build.gradle.kts`. -2. Create `ui/ViewModel.kt` extending `ViewModel`. -3. Expose UI state as `StateFlow`. -4. Inject into the Composable via `viewModel()`: +- `ApiClient` — singleton `OkHttpClient` with `AuthInterceptor` that attaches the Firebase ID token as `Authorization: Bearer ` on every request. +- `ApiResponse` / `ApiErrorResponse` / `ApiErrorDetail` — envelope types that match the Go backend's `{"data": ...}` / `{"error": {"code": "...", "message": "..."}}` response shape. +- `UserApi` — suspend functions for the `/api/v1/me` endpoint; accepts injectable `baseUrl` and `client` parameters for unit testing with `MockWebServer`. -```kotlin -@Composable -fun FeatureScreen(viewModel: FeatureViewModel = viewModel()) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - // render uiState -} -``` +See `mobile/docs/http-client.md` for the full pattern, instructions on adding new API calls, and the testing approach. diff --git a/mobile/docs/http-client.md b/mobile/docs/http-client.md new file mode 100644 index 0000000..a442296 --- /dev/null +++ b/mobile/docs/http-client.md @@ -0,0 +1,225 @@ +--- +topic: HTTP client and API layer +last_verified: 2026-06-25 +sources: + - app/src/main/java/com/company/template/data/network/ApiClient.kt + - app/src/main/java/com/company/template/data/network/ApiResponse.kt + - app/src/main/java/com/company/template/data/network/UserApi.kt + - app/src/main/java/com/company/template/home/HomeScreen.kt + - app/src/test/java/com/company/template/data/network/UserApiTest.kt + - app/build.gradle.kts +--- + +# HTTP client and API layer + +## Package structure + +``` +data/network/ + ApiClient.kt — singleton OkHttpClient with Firebase auth interceptor + ApiResponse.kt — envelope types matching the Go backend's response shape + UserApi.kt — UserProfile model + getMe() suspend function +``` + +## ApiClient + +`ApiClient` is a Kotlin `object` (singleton). It exposes a single `httpClient: OkHttpClient` that has `AuthInterceptor` attached. + +```kotlin +object ApiClient { + val httpClient: OkHttpClient = OkHttpClient.Builder() + .addInterceptor(AuthInterceptor()) + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() +} +``` + +### AuthInterceptor + +`AuthInterceptor` is a private inner class that adds `Authorization: Bearer ` to every outgoing request. + +```kotlin +private class AuthInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val token = runCatching { + FirebaseAuth.getInstance().currentUser + ?.getIdToken(false) + ?.result + ?.token + }.getOrNull() + + val request = if (token != null) { + chain.request().newBuilder() + .header("Authorization", "Bearer $token") + .build() + } else { + chain.request() + } + return chain.proceed(request) + } +} +``` + +`getIdToken(false)` returns the cached token if it is still valid; it does not force a refresh. The `.result` property is the synchronous accessor on the Firebase `Task`. This is safe here because `intercept()` always executes on an OkHttp dispatcher thread, never on the main thread. + +If the current user is null or token retrieval fails, the request is forwarded without an `Authorization` header rather than throwing. + +## Envelope types + +The Go backend wraps all responses in a consistent envelope. The Kotlin types mirror that shape exactly: + +| Go shape | Kotlin type | +|---|---| +| `{"data": }` | `ApiResponse(val data: T)` | +| `{"error": {"code": "...", "message": "..."}}` | `ApiErrorResponse(val error: ApiErrorDetail)` | +| `{"code": "...", "message": "..."}` | `ApiErrorDetail(val code: String, val message: String)` | + +All three types are annotated with `@Serializable` (kotlinx.serialization). + +## UserApi + +`UserApi` is a Kotlin `object` with a single suspend function `getMe()` that calls `GET /api/v1/me` and returns `Result`. + +```kotlin +object UserApi { + private val json = Json { ignoreUnknownKeys = true } + + suspend fun getMe( + baseUrl: String = BuildConfig.BACKEND_URL, + client: OkHttpClient = ApiClient.httpClient, + ): Result = runCatching { + val request = Request.Builder() + .url("$baseUrl/api/v1/me") + .get() + .build() + + client.newCall(request).execute().use { response -> + val body = response.body?.string() ?: error("empty body") + if (!response.isSuccessful) { + val err = json.decodeFromString(body) + error(err.error.message) + } + json.decodeFromString>(body).data + } + } +} +``` + +`UserProfile` is defined in the same file: + +```kotlin +@Serializable +data class UserProfile( + val uid: String, + val email: String? = null, + val displayName: String? = null, +) +``` + +`ignoreUnknownKeys = true` on the `Json` instance ensures the client does not break when the backend adds new fields. + +## BACKEND_URL build config field + +`BuildConfig.BACKEND_URL` is injected at build time from `local.properties`: + +```kotlin +// app/build.gradle.kts +buildConfigField( + "String", + "BACKEND_URL", + "\"${localProps.getProperty("BACKEND_URL", "http://10.0.2.2:8080")}\"", +) +``` + +`http://10.0.2.2:8080` is the Android emulator's alias for the host machine's `localhost`. This default works for local development without any `local.properties` entry. + +To override for a physical device or a staging server, add to `mobile/local.properties`: + +```properties +BACKEND_URL=http://192.168.1.x:8080 +``` + +`local.properties` is gitignored; never commit it. + +## Calling an API from a Composable + +Use `LaunchedEffect` to launch the coroutine and `withContext(Dispatchers.IO)` to move the blocking OkHttp call off the main thread. Store result in `remember` state: + +```kotlin +var profile by remember { mutableStateOf(null) } +var profileError by remember { mutableStateOf(null) } + +LaunchedEffect(Unit) { + val result = withContext(Dispatchers.IO) { UserApi.getMe() } + result + .onSuccess { profile = it } + .onFailure { profileError = it.message } +} +``` + +Source: `home/HomeScreen.kt`. + +## Adding a new API call + +Follow the `UserApi` pattern: + +1. Add a new `@Serializable` response model in `data/network/`. +2. Add a suspend function to an `object` (or a new `object` for a new resource) with `baseUrl` and `client` as defaulted parameters. +3. Use `runCatching { }` so all exceptions are captured in `Result`. +4. Decode success bodies as `ApiResponse`, error bodies as `ApiErrorResponse`. +5. Call from a Composable via `LaunchedEffect` + `withContext(Dispatchers.IO)`. + +## Testing + +API functions are tested with `MockWebServer` (from `com.squareup.okhttp3:mockwebserver`) in JVM unit tests under `src/test/`. No Android framework is needed. + +```kotlin +class UserApiTest { + + private lateinit var server: MockWebServer + private val testClient = OkHttpClient() // no AuthInterceptor + + @Before fun setUp() { server = MockWebServer(); server.start() } + @After fun tearDown() { server.shutdown() } + + @Test + fun `getMe returns UserProfile on successful response`() = runTest { + server.enqueue( + MockResponse() + .setResponseCode(200) + .setBody("""{"data":{"uid":"u1","email":"a@b.com"}}""") + ) + + val result = UserApi.getMe( + baseUrl = server.url("/").toString().trimEnd('/'), + client = testClient, + ) + + assertTrue(result.isSuccess) + assertEquals("u1", result.getOrThrow().uid) + } + + @Test + fun `getMe returns failure with backend message on error response`() = runTest { + server.enqueue( + MockResponse() + .setResponseCode(401) + .setBody("""{"error":{"code":"UNAUTHENTICATED","message":"no token"}}""") + ) + + val result = UserApi.getMe( + baseUrl = server.url("/").toString().trimEnd('/'), + client = testClient, + ) + + assertTrue(result.isFailure) + assertEquals("no token", result.exceptionOrNull()?.message) + } +} +``` + +Key points: +- Pass the `MockWebServer` URL as `baseUrl` and a plain `OkHttpClient()` as `client` — this bypasses `AuthInterceptor` and avoids any Firebase dependency in tests. +- Use `kotlinx-coroutines-test` (`runTest`) for suspend functions. +- Tests live in `src/test/` (JVM), not `src/androidTest/`. diff --git a/web/.env.example b/web/.env.example index ba369f9..4af5709 100644 --- a/web/.env.example +++ b/web/.env.example @@ -22,6 +22,19 @@ NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID= # optional # Firebase Cloud Messaging — VAPID key from Firebase Console > Cloud Messaging NEXT_PUBLIC_FIREBASE_VAPID_KEY= -# Go backend base URL +# Go backend base URL (used by server-side tRPC routers, not exposed to the browser) +BACKEND_URL=http://localhost:8080 + +# Go backend base URL (client-side — used from browser, e.g. storage presign calls) +NEXT_PUBLIC_BACKEND_URL=http://localhost:8080 + +# Go backend base URL (legacy alias — prefer BACKEND_URL / NEXT_PUBLIC_BACKEND_URL above) NEXT_PUBLIC_API_URL=http://localhost:8080 - + +# Sentry source-map upload — only needed in CI when NEXT_PUBLIC_SENTRY_DSN is set +SENTRY_ORG= # your Sentry organisation slug +SENTRY_PROJECT= # your Sentry project slug + +# Vercel deployment URL — set automatically by Vercel; used to build absolute tRPC URLs +# VERCEL_URL= # e.g. my-app.vercel.app (no https://) + diff --git a/web/app/__tests__/error.test.tsx b/web/app/__tests__/error.test.tsx new file mode 100644 index 0000000..248ff8f --- /dev/null +++ b/web/app/__tests__/error.test.tsx @@ -0,0 +1,43 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import ErrorPage from '../error' + +describe('ErrorPage', () => { + it('renders a friendly error heading', () => { + const error = new Error('Something went wrong') + const reset = vi.fn() + render() + expect(screen.getByRole('heading')).toBeInTheDocument() + }) + + it('renders the error message in a paragraph', () => { + const error = new Error('Network connection lost') + const reset = vi.fn() + render() + expect(screen.getByText(/network connection lost/i)).toBeInTheDocument() + }) + + it('calls reset when the Try again button is clicked', async () => { + const user = userEvent.setup() + const error = new Error('Boom') + const reset = vi.fn() + render() + await user.click(screen.getByRole('button', { name: /try again/i })) + expect(reset).toHaveBeenCalledTimes(1) + }) + + it('shows the digest when present', () => { + const error = Object.assign(new Error('Boom'), { digest: 'abc-123' }) + const reset = vi.fn() + render() + expect(screen.getByText(/abc-123/)).toBeInTheDocument() + }) + + it('does not render digest text when digest is absent', () => { + const error = new Error('Boom') + const reset = vi.fn() + render() + expect(screen.queryByTestId('error-digest')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/error.tsx b/web/app/error.tsx new file mode 100644 index 0000000..63ebbc9 --- /dev/null +++ b/web/app/error.tsx @@ -0,0 +1,32 @@ +"use client" + +import { Button } from "@/components/ui/button" + +interface ErrorPageProps { + error: Error & { digest?: string } + reset: () => void +} + +export default function ErrorPage({ error, reset }: ErrorPageProps) { + return ( +
+
+

+ Something went wrong +

+

+ {error.message || "An unexpected error occurred. Please try again."} +

+ {error.digest && ( +

+ Reference: {error.digest} +

+ )} +
+ +
+ ) +} diff --git a/web/app/global-error.tsx b/web/app/global-error.tsx new file mode 100644 index 0000000..c15f348 --- /dev/null +++ b/web/app/global-error.tsx @@ -0,0 +1,34 @@ +"use client" + +import { Button } from "@/components/ui/button" + +interface GlobalErrorProps { + error: Error & { digest?: string } + reset: () => void +} + +export default function GlobalError({ error, reset }: GlobalErrorProps) { + return ( + + +
+
+

+ Something went wrong +

+

+ {error.message || + "A critical error occurred. Please reload the page."} +

+ {error.digest && ( +

+ Reference: {error.digest} +

+ )} +
+ +
+ + + ) +} diff --git a/web/app/not-found.tsx b/web/app/not-found.tsx new file mode 100644 index 0000000..be37c3d --- /dev/null +++ b/web/app/not-found.tsx @@ -0,0 +1,20 @@ +import Link from "next/link" +import { Button } from "@/components/ui/button" + +export default function NotFound() { + return ( +
+
+

+ 404 – Page not found +

+

+ The page you're looking for doesn't exist or has been moved. +

+
+ +
+ ) +} diff --git a/web/docs/routing.md b/web/docs/routing.md index 293b792..70931df 100644 --- a/web/docs/routing.md +++ b/web/docs/routing.md @@ -1,9 +1,12 @@ --- topic: routing -last_verified: 2026-06-14 +last_verified: 2026-06-25 sources: - app/layout.tsx - app/page.tsx + - app/not-found.tsx + - app/error.tsx + - app/global-error.tsx - next.config.ts --- @@ -20,6 +23,7 @@ App Router only. No Pages Router. Never create files in a `pages/` directory. | `app/loading.tsx` | Suspense boundary shown while page data loads | | `app/error.tsx` | Error boundary for a segment (must be `"use client"`) | | `app/not-found.tsx` | 404 UI for the segment | +| `app/global-error.tsx` | Top-level error boundary that catches errors in the root layout (must be `"use client"`; must include its own `` and `` tags) | ## Root layout (`app/layout.tsx`) - Must export `metadata` and a default `RootLayout` component. @@ -60,3 +64,17 @@ Do not add `"use client"` to layout or page files unless you have a concrete rea ## Navigation Use `` from `next/link` — never `` for internal navigation. Programmatic navigation: `import { useRouter } from 'next/navigation'` (client components only). + +## Error pages + +Three special files handle runtime errors and missing routes at the root segment level. + +| File | Trigger | Component type | Notes | +|---|---|---|---| +| `app/not-found.tsx` | `notFound()` call or unmatched URL | Server Component | Renders a 404 page with a link back to `/`. No `"use client"` directive. | +| `app/error.tsx` | Uncaught error thrown inside a route segment | Client Component | Receives `error: Error & { digest?: string }` and `reset: () => void` props. The "Try again" button calls `reset()` to re-render the segment. | +| `app/global-error.tsx` | Uncaught error in the root layout itself | Client Component | Same props as `error.tsx`. Must render its own `` and `` tags because the root layout is unavailable when this boundary fires. | + +`error.tsx` and `global-error.tsx` must have `"use client"` at the top — Next.js requires error boundaries to be Client Components. `not-found.tsx` has no such requirement and is a Server Component. + +`error.digest` is an opaque server-generated hash surfaced in both the UI and server logs; display it as a reference string when present so users can report it.