feat: implement Iteration 0 foundation (backend + Flutter client)

Backend (Go):
- Project structure with chi router, pgxpool, goose migrations
- JWT auth (access/refresh tokens) with Firebase token verification
- NoopTokenVerifier for local dev without Firebase credentials
- PostgreSQL user repository with atomic profile updates (transactions)
- Mifflin-St Jeor calorie calculation based on profile data
- REST API: POST /auth/login, /auth/refresh, /auth/logout, GET/PUT /profile, GET /health
- Middleware: auth, CORS (localhost wildcard), logging, recovery, request_id
- Unit tests (51 passing) and integration tests (testcontainers)
- Docker Compose setup with postgres healthcheck and graceful shutdown

Flutter client:
- Riverpod state management with GoRouter navigation
- Firebase Auth (email/password + Google sign-in with web popup support)
- Platform-aware API URLs (web/Android/iOS)
- Dio HTTP client with JWT auth interceptor and concurrent refresh handling
- Secure token storage
- Screens: Login, Register, Home (tabs: Menu, Recipes, Products, Profile)
- Unit tests (17 passing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-02-20 13:14:58 +02:00
commit 24219b611e
140 changed files with 13062 additions and 0 deletions

View File

@@ -0,0 +1,106 @@
package auth
import (
"encoding/json"
"log/slog"
"net/http"
"github.com/food-ai/backend/internal/middleware"
)
const maxRequestBodySize = 1 << 20 // 1 MB
type Handler struct {
service *Service
}
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
type loginRequest struct {
FirebaseToken string `json:"firebase_token"`
}
type refreshRequest struct {
RefreshToken string `json:"refresh_token"`
}
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErrorJSON(w, http.StatusBadRequest, "invalid request body")
return
}
if req.FirebaseToken == "" {
writeErrorJSON(w, http.StatusBadRequest, "firebase_token is required")
return
}
resp, err := h.service.Login(r.Context(), req.FirebaseToken)
if err != nil {
writeErrorJSON(w, http.StatusUnauthorized, "authentication failed")
return
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
var req refreshRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErrorJSON(w, http.StatusBadRequest, "invalid request body")
return
}
if req.RefreshToken == "" {
writeErrorJSON(w, http.StatusBadRequest, "refresh_token is required")
return
}
resp, err := h.service.Refresh(r.Context(), req.RefreshToken)
if err != nil {
writeErrorJSON(w, http.StatusUnauthorized, "invalid refresh token")
return
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context())
if userID == "" {
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized")
return
}
if err := h.service.Logout(r.Context(), userID); err != nil {
writeErrorJSON(w, http.StatusInternalServerError, "logout failed")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
type errorResponse struct {
Error string `json:"error"`
}
func writeErrorJSON(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(errorResponse{Error: msg}); err != nil {
slog.Error("failed to write error response", "err", err)
}
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
slog.Error("failed to write JSON response", "err", err)
}
}