package handler import ( "encoding/json" "errors" "net/http" "time" "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/wotra/wotra/internal/domain" "github.com/wotra/wotra/internal/service" ) // WeekHandler serves /api/weeks and /api/balance/adjustments routes. type WeekHandler struct { svc *service.WeekService } func NewWeekHandler(svc *service.WeekService) *WeekHandler { return &WeekHandler{svc: svc} } func (h *WeekHandler) Routes(r chi.Router) { r.Get("/weeks", h.List) r.Get("/weeks/balance", h.Balance) r.Post("/weeks/{week_key}/close", h.Close) r.Delete("/weeks/{week_key}/close", h.Reopen) r.Get("/balance/adjustments", h.ListAdjustments) r.Post("/balance/adjustments", h.CreateAdjustment) r.Put("/balance/adjustments/{id}", h.UpdateAdjustment) r.Delete("/balance/adjustments/{id}", h.DeleteAdjustment) } // Balance GET /api/weeks/balance func (h *WeekHandler) Balance(w http.ResponseWriter, r *http.Request) { bal, err := h.svc.Balance(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, bal) } // List GET /api/weeks?from=YYYY-Www&to=YYYY-Www func (h *WeekHandler) List(w http.ResponseWriter, r *http.Request) { from := r.URL.Query().Get("from") to := r.URL.Query().Get("to") if from == "" { from = "0000-W01" } if to == "" { to = "9999-W53" } weeks, err := h.svc.ListWeeks(r.Context(), from, to) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } if weeks == nil { writeJSON(w, http.StatusOK, []struct{}{}) return } writeJSON(w, http.StatusOK, weeks) } // Close POST /api/weeks/{week_key}/close func (h *WeekHandler) Close(w http.ResponseWriter, r *http.Request) { weekKey := chi.URLParam(r, "week_key") cw, err := h.svc.CloseWeek(r.Context(), weekKey) if err != nil { switch { case errors.Is(err, service.ErrWeekAlreadyClosed): writeError(w, http.StatusConflict, err.Error()) case errors.Is(err, service.ErrWeekHasUnclosedDays): writeError(w, http.StatusUnprocessableEntity, err.Error()) case errors.Is(err, service.ErrNoSettings): writeError(w, http.StatusUnprocessableEntity, err.Error()) default: writeError(w, http.StatusInternalServerError, err.Error()) } return } writeJSON(w, http.StatusOK, cw) } // Reopen DELETE /api/weeks/{week_key}/close func (h *WeekHandler) Reopen(w http.ResponseWriter, r *http.Request) { weekKey := chi.URLParam(r, "week_key") if err := h.svc.ReopenWeek(r.Context(), weekKey); err != nil { switch { case errors.Is(err, service.ErrWeekNotClosed): writeError(w, http.StatusNotFound, err.Error()) default: writeError(w, http.StatusInternalServerError, err.Error()) } return } w.WriteHeader(http.StatusNoContent) } // ── Balance adjustments ─────────────────────────────────────────────────────── // ListAdjustments GET /api/balance/adjustments func (h *WeekHandler) ListAdjustments(w http.ResponseWriter, r *http.Request) { list, err := h.svc.ListAdjustments(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } if list == nil { writeJSON(w, http.StatusOK, []*domain.BalanceAdjustment{}) return } writeJSON(w, http.StatusOK, list) } type adjustmentRequest struct { DeltaMs int64 `json:"delta_ms"` Note string `json:"note"` EffectiveAt *int64 `json:"effective_at"` // optional; defaults to now } // CreateAdjustment POST /api/balance/adjustments func (h *WeekHandler) CreateAdjustment(w http.ResponseWriter, r *http.Request) { var req adjustmentRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid JSON") return } now := time.Now().UnixMilli() effectiveAt := now if req.EffectiveAt != nil { effectiveAt = *req.EffectiveAt } a := &domain.BalanceAdjustment{ ID: uuid.New().String(), DeltaMs: req.DeltaMs, Note: req.Note, EffectiveAt: effectiveAt, CreatedAt: now, UpdatedAt: now, } created, err := h.svc.CreateAdjustment(r.Context(), a) if err != nil { if errors.Is(err, service.ErrZeroAdjustment) { writeError(w, http.StatusBadRequest, err.Error()) return } writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusCreated, created) } // UpdateAdjustment PUT /api/balance/adjustments/{id} func (h *WeekHandler) UpdateAdjustment(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") var req adjustmentRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid JSON") return } now := time.Now().UnixMilli() effectiveAt := now if req.EffectiveAt != nil { effectiveAt = *req.EffectiveAt } a := &domain.BalanceAdjustment{ ID: id, DeltaMs: req.DeltaMs, Note: req.Note, EffectiveAt: effectiveAt, UpdatedAt: now, } updated, err := h.svc.UpdateAdjustment(r.Context(), a) if err != nil { switch { case errors.Is(err, service.ErrZeroAdjustment): writeError(w, http.StatusBadRequest, err.Error()) case errors.Is(err, service.ErrAdjustmentNotFound): writeError(w, http.StatusNotFound, err.Error()) default: writeError(w, http.StatusInternalServerError, err.Error()) } return } writeJSON(w, http.StatusOK, updated) } // DeleteAdjustment DELETE /api/balance/adjustments/{id} func (h *WeekHandler) DeleteAdjustment(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") if err := h.svc.DeleteAdjustment(r.Context(), id); err != nil { switch { case errors.Is(err, service.ErrAdjustmentNotFound): writeError(w, http.StatusNotFound, err.Error()) default: writeError(w, http.StatusInternalServerError, err.Error()) } return } w.WriteHeader(http.StatusNoContent) }