- New balance_adjustments table with CRUD store, sync logging, and service methods
- SQL migrations restructured: embed fs.FS from internal/store/migrations/, apply in order via user_version
- WeekService.Balance combines closed-weeks delta + adjustments delta; BalanceSummary breakdown
- Four REST routes: GET/POST /api/balance/adjustments, PUT/DELETE /api/balance/adjustments/{id}
- Dexie schema v2 + sync apply cases for balance_adjustments
- API client: BalanceAdjustment type, balance namespace (list/create/update/delete)
- utils: composeDeltaMs / decomposeDeltaMs helpers + 8 new Vitest tests (19 total, all passing)
- History page: balance card breakdown line + full adjustments section with inline add/edit/delete
206 lines
5.8 KiB
Go
206 lines
5.8 KiB
Go
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)
|
|
}
|