feat(m1): backend scaffold - entries CRUD, start/stop, auth, migrations

This commit is contained in:
2026-04-30 16:35:06 +02:00
parent 4905c6f570
commit 3aa068efd2
19 changed files with 1483 additions and 0 deletions

24
internal/handler/auth.go Normal file
View File

@@ -0,0 +1,24 @@
package handler
import (
"net/http"
)
// AuthMiddleware returns a middleware that validates the Bearer token.
func AuthMiddleware(token string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
const prefix = "Bearer "
auth := r.Header.Get("Authorization")
if len(auth) < len(prefix) || auth[:len(prefix)] != prefix {
writeError(w, http.StatusUnauthorized, "missing or malformed Authorization header")
return
}
if auth[len(prefix):] != token {
writeError(w, http.StatusUnauthorized, "invalid token")
return
}
next.ServeHTTP(w, r)
})
}
}

View File

@@ -0,0 +1,129 @@
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/{id}/stop", h.StopByID)
r.Get("/entries", h.List)
r.Put("/entries/{id}", h.Update)
r.Delete("/entries/{id}", h.Delete)
}
// 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())
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)
}

View File

@@ -0,0 +1,20 @@
package handler
import (
"encoding/json"
"net/http"
)
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]string{"error": msg})
}
func decodeJSON(r *http.Request, v any) error {
return json.NewDecoder(r.Body).Decode(v)
}

View File

@@ -0,0 +1,33 @@
package handler
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/wotra/wotra/internal/service"
)
// NewRouter builds the full HTTP router.
func NewRouter(authToken string, entrySvc *service.EntryService) http.Handler {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
// Unauthenticated
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
// Authenticated API
r.Route("/api", func(r chi.Router) {
r.Use(AuthMiddleware(authToken))
entryH := NewEntryHandler(entrySvc)
entryH.Routes(r)
})
return r
}