- Migration 003: adds logged_at to sync_log for TTL pruning; migrates settings_history to UUID TEXT PK with updated_at column - SyncStore: Prune() deletes rows older than 30d and writes a '_pruned' marker at the boundary version; Pull() calls Prune lazily and returns ErrSyncStale (410) when the client's since_version is behind the marker - sync_handler.go: GET /api/sync/pull?since=N; POST /api/sync/push with last-updated_at-wins conflict resolution for entries, balance_adjustments, settings_history; closed_days/closed_weeks skipped (server-only mutations) - router.go: passes entryStore, adjustmentStore, settingsStore to SyncHandler - settings_store.go: UUID PK, updated_at column, Upsert() for push path - settings_service.go: generates UUID on create, sets updated_at on update - settings_handler.go: ID params changed from int64 to string - domain.go: Settings.ID string, Settings.UpdatedAt added - client.ts: all mutation methods catch TypeError (offline) and fall back to Dexie write + outbox enqueue; crypto.randomUUID() for offline creates; Settings.id type changed to string - db.ts: Dexie v3 — settings_history key path changed to string UUID; upgrade handler clears table for repopulation via pull - sync.ts: real pushOutbox to POST /api/sync/push; pullChanges uses GET with ?since=N; 410 triggers coldStart() + retry; coldStart() wipes all tables and resets last_version - 4 new Go store tests covering normal pull, stale client, empty prune, client-ahead-of-marker; all tests pass (store + service, 19 Vitest)
140 lines
4.0 KiB
Go
140 lines
4.0 KiB
Go
package handler
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/wotra/wotra/internal/service"
|
|
)
|
|
|
|
// SettingsHandler serves /api/settings routes.
|
|
type SettingsHandler struct {
|
|
svc *service.SettingsService
|
|
}
|
|
|
|
func NewSettingsHandler(svc *service.SettingsService) *SettingsHandler {
|
|
return &SettingsHandler{svc: svc}
|
|
}
|
|
|
|
func (h *SettingsHandler) Routes(r chi.Router) {
|
|
r.Get("/settings", h.Current)
|
|
r.Put("/settings", h.Upsert)
|
|
r.Get("/settings/history", h.History)
|
|
r.Put("/settings/history/{id}", h.UpdateHistoryRow)
|
|
r.Delete("/settings/history/{id}", h.DeleteHistoryRow)
|
|
}
|
|
|
|
// Current GET /api/settings
|
|
func (h *SettingsHandler) Current(w http.ResponseWriter, r *http.Request) {
|
|
set, err := h.svc.Current(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, set)
|
|
}
|
|
|
|
// Upsert PUT /api/settings
|
|
func (h *SettingsHandler) Upsert(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
EffectiveFrom string `json:"effective_from"`
|
|
HoursPerWeek float64 `json:"hours_per_week"`
|
|
WorkdaysMask int `json:"workdays_mask"`
|
|
Timezone string `json:"timezone"`
|
|
}
|
|
if err := decodeJSON(r, &body); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
|
return
|
|
}
|
|
set, err := h.svc.Upsert(r.Context(), service.UpsertSettingsInput{
|
|
EffectiveFrom: body.EffectiveFrom,
|
|
HoursPerWeek: body.HoursPerWeek,
|
|
WorkdaysMask: body.WorkdaysMask,
|
|
Timezone: body.Timezone,
|
|
})
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, service.ErrInvalidHours), errors.Is(err, service.ErrInvalidWorkdaysMask):
|
|
writeError(w, http.StatusUnprocessableEntity, err.Error())
|
|
default:
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
}
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, set)
|
|
}
|
|
|
|
// History GET /api/settings/history
|
|
func (h *SettingsHandler) History(w http.ResponseWriter, r *http.Request) {
|
|
history, err := h.svc.History(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if history == nil {
|
|
writeJSON(w, http.StatusOK, []struct{}{})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, history)
|
|
}
|
|
|
|
// UpdateHistoryRow PUT /api/settings/history/{id}
|
|
func (h *SettingsHandler) UpdateHistoryRow(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
if id == "" {
|
|
writeError(w, http.StatusBadRequest, "invalid id")
|
|
return
|
|
}
|
|
var body struct {
|
|
EffectiveFrom string `json:"effective_from"`
|
|
HoursPerWeek float64 `json:"hours_per_week"`
|
|
WorkdaysMask int `json:"workdays_mask"`
|
|
Timezone string `json:"timezone"`
|
|
}
|
|
if err := decodeJSON(r, &body); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
|
return
|
|
}
|
|
set, err := h.svc.UpdateSettings(r.Context(), id, service.UpdateSettingsInput{
|
|
EffectiveFrom: body.EffectiveFrom,
|
|
HoursPerWeek: body.HoursPerWeek,
|
|
WorkdaysMask: body.WorkdaysMask,
|
|
Timezone: body.Timezone,
|
|
})
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, service.ErrSettingsNotFound), errors.Is(err, sql.ErrNoRows):
|
|
writeError(w, http.StatusNotFound, err.Error())
|
|
case errors.Is(err, service.ErrInvalidHours), errors.Is(err, service.ErrInvalidWorkdaysMask):
|
|
writeError(w, http.StatusUnprocessableEntity, err.Error())
|
|
default:
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
}
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, set)
|
|
}
|
|
|
|
// DeleteHistoryRow DELETE /api/settings/history/{id}
|
|
func (h *SettingsHandler) DeleteHistoryRow(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
if id == "" {
|
|
writeError(w, http.StatusBadRequest, "invalid id")
|
|
return
|
|
}
|
|
if err := h.svc.DeleteSettings(r.Context(), id); err != nil {
|
|
switch {
|
|
case errors.Is(err, service.ErrSettingsNotFound):
|
|
writeError(w, http.StatusNotFound, err.Error())
|
|
case errors.Is(err, service.ErrLastSettingsRow):
|
|
writeError(w, http.StatusConflict, err.Error())
|
|
default:
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
}
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|