- Add ErrFutureDay sentinel error - CreateInterval: rejects if startDayKey > todayKey (400) - Update: rejects if new start_time moves entry to a future day (400) - Handler maps ErrFutureDay → 400 Bad Request for both endpoints - Add TestCreateIntervalRejectsFutureDay - Add TestUpdateRejectsMoveToFutureDay - UI already gates this via dayCapabilities (canAddInterval=false, canEditEntries=false for future days), but server now enforces it too
169 lines
4.7 KiB
Go
169 lines
4.7 KiB
Go
package handler
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/wotra/wotra/internal/service"
|
|
)
|
|
|
|
// EntryHandler serves /api/entries routes.
|
|
type EntryHandler struct {
|
|
svc *service.EntryService
|
|
}
|
|
|
|
func NewEntryHandler(svc *service.EntryService) *EntryHandler {
|
|
return &EntryHandler{svc: svc}
|
|
}
|
|
|
|
func (h *EntryHandler) Routes(r chi.Router) {
|
|
r.Post("/entries/start", h.Start)
|
|
r.Post("/entries", h.CreateInterval)
|
|
r.Post("/entries/{id}/stop", h.StopByID)
|
|
r.Get("/entries", h.List)
|
|
r.Put("/entries/{id}", h.Update)
|
|
r.Delete("/entries/{id}", h.Delete)
|
|
}
|
|
|
|
// CreateInterval POST /api/entries — create a completed interval with explicit times
|
|
func (h *EntryHandler) CreateInterval(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
StartTime int64 `json:"start_time"`
|
|
EndTime int64 `json:"end_time"`
|
|
Note string `json:"note"`
|
|
}
|
|
if err := decodeJSON(r, &body); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
|
return
|
|
}
|
|
if body.StartTime == 0 || body.EndTime == 0 {
|
|
writeError(w, http.StatusBadRequest, "start_time and end_time are required")
|
|
return
|
|
}
|
|
entry, err := h.svc.CreateInterval(r.Context(), service.CreateIntervalInput{
|
|
StartTime: body.StartTime,
|
|
EndTime: body.EndTime,
|
|
Note: body.Note,
|
|
})
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, service.ErrCrossesMidnight):
|
|
writeError(w, http.StatusUnprocessableEntity, err.Error())
|
|
case errors.Is(err, service.ErrDayAlreadyClosed):
|
|
writeError(w, http.StatusConflict, err.Error())
|
|
case errors.Is(err, service.ErrFutureDay):
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
default:
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
}
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, entry)
|
|
}
|
|
|
|
// Start POST /api/entries/start
|
|
func (h *EntryHandler) Start(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
Note string `json:"note"`
|
|
}
|
|
_ = decodeJSON(r, &body) // note is optional
|
|
|
|
entry, err := h.svc.Start(r.Context(), body.Note)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, service.ErrEntryRunning):
|
|
writeError(w, http.StatusConflict, err.Error())
|
|
case errors.Is(err, service.ErrDayAlreadyClosed):
|
|
writeError(w, http.StatusConflict, err.Error())
|
|
default:
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
}
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, entry)
|
|
}
|
|
|
|
// StopByID POST /api/entries/{id}/stop
|
|
func (h *EntryHandler) StopByID(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
entry, err := h.svc.StopByID(r.Context(), id)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, service.ErrEntryNotFound):
|
|
writeError(w, http.StatusNotFound, err.Error())
|
|
case errors.Is(err, service.ErrEntryNotRunning):
|
|
writeError(w, http.StatusConflict, err.Error())
|
|
default:
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
}
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, entry)
|
|
}
|
|
|
|
// List GET /api/entries?from=YYYY-MM-DD&to=YYYY-MM-DD
|
|
func (h *EntryHandler) List(w http.ResponseWriter, r *http.Request) {
|
|
from := r.URL.Query().Get("from")
|
|
to := r.URL.Query().Get("to")
|
|
if from == "" {
|
|
from = "0000-01-01"
|
|
}
|
|
if to == "" {
|
|
to = "9999-12-31"
|
|
}
|
|
entries, err := h.svc.List(r.Context(), from, to)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if entries == nil {
|
|
writeJSON(w, http.StatusOK, []struct{}{})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, entries)
|
|
}
|
|
|
|
// Update PUT /api/entries/{id}
|
|
func (h *EntryHandler) Update(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
var body struct {
|
|
StartTime *int64 `json:"start_time"`
|
|
EndTime *int64 `json:"end_time"`
|
|
Note *string `json:"note"`
|
|
}
|
|
if err := decodeJSON(r, &body); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
|
return
|
|
}
|
|
entry, err := h.svc.Update(r.Context(), id, service.UpdateEntryInput{
|
|
StartTime: body.StartTime,
|
|
EndTime: body.EndTime,
|
|
Note: body.Note,
|
|
})
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, service.ErrEntryNotFound):
|
|
writeError(w, http.StatusNotFound, err.Error())
|
|
case errors.Is(err, service.ErrCrossesMidnight):
|
|
writeError(w, http.StatusUnprocessableEntity, err.Error())
|
|
case errors.Is(err, service.ErrFutureDay):
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
default:
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
}
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, entry)
|
|
}
|
|
|
|
// Delete DELETE /api/entries/{id}
|
|
func (h *EntryHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
if err := h.svc.Delete(r.Context(), id); err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|