feat(m1): backend scaffold - entries CRUD, start/stop, auth, migrations
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
wotra.db
|
||||
105
cmd/wotra/main.go
Normal file
105
cmd/wotra/main.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/wotra/wotra/internal/config"
|
||||
"github.com/wotra/wotra/internal/handler"
|
||||
"github.com/wotra/wotra/internal/service"
|
||||
"github.com/wotra/wotra/internal/store"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
slog.Error("config error", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
tz, err := time.LoadLocation(cfg.Timezone)
|
||||
if err != nil {
|
||||
slog.Error("invalid timezone", "tz", cfg.Timezone, "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
db, err := store.Open(cfg.DBPath)
|
||||
if err != nil {
|
||||
slog.Error("database error", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
entryStore := store.NewEntryStore(db)
|
||||
closedDayStore := store.NewClosedDayStore(db)
|
||||
settingsStore := store.NewSettingsStore(db)
|
||||
|
||||
entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, tz)
|
||||
|
||||
// Background goroutine: auto-stop entries that cross midnight
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go runMidnightGuard(ctx, entrySvc)
|
||||
|
||||
router := handler.NewRouter(cfg.AuthToken, entrySvc)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":" + cfg.Port,
|
||||
Handler: router,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
slog.Info("starting server", "port", cfg.Port, "db", cfg.DBPath, "tz", cfg.Timezone)
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
slog.Error("server error", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
<-quit
|
||||
slog.Info("shutting down")
|
||||
cancel()
|
||||
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer shutdownCancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
slog.Error("shutdown error", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// runMidnightGuard checks every minute whether any running entries span into a new day
|
||||
// and auto-stops them.
|
||||
func runMidnightGuard(ctx context.Context, svc *service.EntryService) {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
ids, err := svc.AutoStopStalledEntries(ctx)
|
||||
if err != nil {
|
||||
slog.Error("midnight guard error", "err", err)
|
||||
continue
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
slog.Info("auto-stopped entries crossing midnight", "ids", ids)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
go.mod
Normal file
17
go.mod
Normal file
@@ -0,0 +1,17 @@
|
||||
module github.com/wotra/wotra
|
||||
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-chi/chi/v5 v5.2.5 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
modernc.org/libc v1.72.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.50.0 // indirect
|
||||
)
|
||||
23
go.sum
Normal file
23
go.sum
Normal file
@@ -0,0 +1,23 @@
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
|
||||
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
|
||||
modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
|
||||
45
internal/config/config.go
Normal file
45
internal/config/config.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Config holds runtime configuration loaded from environment variables.
|
||||
type Config struct {
|
||||
AuthToken string
|
||||
Port string
|
||||
DBPath string
|
||||
Timezone string
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables, returning an error if
|
||||
// required variables are missing.
|
||||
func Load() (*Config, error) {
|
||||
token := os.Getenv("AUTH_TOKEN")
|
||||
if token == "" {
|
||||
return nil, fmt.Errorf("AUTH_TOKEN environment variable is required")
|
||||
}
|
||||
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
|
||||
dbPath := os.Getenv("DB_PATH")
|
||||
if dbPath == "" {
|
||||
dbPath = "wotra.db"
|
||||
}
|
||||
|
||||
tz := os.Getenv("TZ")
|
||||
if tz == "" {
|
||||
tz = "UTC"
|
||||
}
|
||||
|
||||
return &Config{
|
||||
AuthToken: token,
|
||||
Port: port,
|
||||
DBPath: dbPath,
|
||||
Timezone: tz,
|
||||
}, nil
|
||||
}
|
||||
119
internal/domain/domain.go
Normal file
119
internal/domain/domain.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package domain
|
||||
|
||||
// DayKind represents the kind of a closed day.
|
||||
type DayKind string
|
||||
|
||||
const (
|
||||
DayKindWork DayKind = "work"
|
||||
DayKindHoliday DayKind = "holiday"
|
||||
DayKindVacation DayKind = "vacation"
|
||||
DayKindSick DayKind = "sick"
|
||||
)
|
||||
|
||||
func (k DayKind) Valid() bool {
|
||||
switch k {
|
||||
case DayKindWork, DayKindHoliday, DayKindVacation, DayKindSick:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Entry is a tracked time range within a single day.
|
||||
type Entry struct {
|
||||
ID string `json:"id"`
|
||||
StartTime int64 `json:"start_time"` // unix ms UTC
|
||||
EndTime *int64 `json:"end_time"` // nil while running
|
||||
AutoStopped bool `json:"auto_stopped"`
|
||||
Note string `json:"note"`
|
||||
DayKey string `json:"day_key"` // YYYY-MM-DD in configured tz
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
DeletedAt *int64 `json:"deleted_at,omitempty"`
|
||||
}
|
||||
|
||||
// IsRunning returns true if the entry has no end time.
|
||||
func (e *Entry) IsRunning() bool {
|
||||
return e.EndTime == nil
|
||||
}
|
||||
|
||||
// DurationMs returns the duration in milliseconds. Returns 0 for running entries.
|
||||
func (e *Entry) DurationMs() int64 {
|
||||
if e.EndTime == nil {
|
||||
return 0
|
||||
}
|
||||
return *e.EndTime - e.StartTime
|
||||
}
|
||||
|
||||
// ClosedDay is the merged result of closing a day.
|
||||
type ClosedDay struct {
|
||||
DayKey string `json:"day_key"`
|
||||
StartTime *int64 `json:"start_time"` // nil for non-work kinds
|
||||
EndTime *int64 `json:"end_time"` // nil for non-work kinds
|
||||
WorkedMs int64 `json:"worked_ms"`
|
||||
Kind DayKind `json:"kind"`
|
||||
ClosedAt int64 `json:"closed_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ClosedWeek is the overtime/undertime snapshot for a week.
|
||||
type ClosedWeek struct {
|
||||
WeekKey string `json:"week_key"` // YYYY-Www ISO week
|
||||
ExpectedMs int64 `json:"expected_ms"`
|
||||
WorkedMs int64 `json:"worked_ms"`
|
||||
DeltaMs int64 `json:"delta_ms"` // worked - expected (signed)
|
||||
ClosedAt int64 `json:"closed_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Settings holds the effective configuration for a period.
|
||||
type Settings struct {
|
||||
ID int64 `json:"id"`
|
||||
EffectiveFrom string `json:"effective_from"` // YYYY-MM-DD
|
||||
HoursPerWeek float64 `json:"hours_per_week"`
|
||||
WorkdaysMask int `json:"workdays_mask"` // bits Mon=1..Sun=64
|
||||
Timezone string `json:"timezone"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
// DailyExpectedMs returns the expected milliseconds for a single workday.
|
||||
func (s *Settings) DailyExpectedMs() int64 {
|
||||
days := popcount(s.WorkdaysMask)
|
||||
if days == 0 {
|
||||
return 0
|
||||
}
|
||||
totalMs := int64(s.HoursPerWeek * 3_600_000)
|
||||
return totalMs / int64(days)
|
||||
}
|
||||
|
||||
// IsWorkday returns true if the given weekday (0=Sunday..6=Saturday, time.Weekday) is a workday.
|
||||
// We use Mon=bit0 .. Sun=bit6 internally.
|
||||
func (s *Settings) IsWorkday(wd int) bool {
|
||||
// time.Weekday: Sunday=0, Monday=1, ..., Saturday=6
|
||||
// our mask: Monday=bit0(1), Tuesday=bit1(2), ... Sunday=bit6(64)
|
||||
var bit int
|
||||
switch wd {
|
||||
case 0: // Sunday
|
||||
bit = 64
|
||||
case 1: // Monday
|
||||
bit = 1
|
||||
case 2:
|
||||
bit = 2
|
||||
case 3:
|
||||
bit = 4
|
||||
case 4:
|
||||
bit = 8
|
||||
case 5:
|
||||
bit = 16
|
||||
case 6: // Saturday
|
||||
bit = 32
|
||||
}
|
||||
return s.WorkdaysMask&bit != 0
|
||||
}
|
||||
|
||||
func popcount(n int) int {
|
||||
count := 0
|
||||
for n != 0 {
|
||||
count += n & 1
|
||||
n >>= 1
|
||||
}
|
||||
return count
|
||||
}
|
||||
24
internal/handler/auth.go
Normal file
24
internal/handler/auth.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
129
internal/handler/entry_handler.go
Normal file
129
internal/handler/entry_handler.go
Normal 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)
|
||||
}
|
||||
20
internal/handler/helpers.go
Normal file
20
internal/handler/helpers.go
Normal 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)
|
||||
}
|
||||
33
internal/handler/router.go
Normal file
33
internal/handler/router.go
Normal 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
|
||||
}
|
||||
217
internal/service/entry_service.go
Normal file
217
internal/service/entry_service.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/wotra/wotra/internal/domain"
|
||||
"github.com/wotra/wotra/internal/store"
|
||||
)
|
||||
|
||||
// Sentinel errors returned by the service layer.
|
||||
var (
|
||||
ErrEntryRunning = errors.New("an entry is already running")
|
||||
ErrEntryNotRunning = errors.New("no running entry found")
|
||||
ErrEntryNotFound = errors.New("entry not found")
|
||||
ErrDayAlreadyClosed = errors.New("day is already closed")
|
||||
ErrDayNotClosed = errors.New("day is not closed")
|
||||
ErrRunningEntryOnDay = errors.New("a running entry exists for this day; stop it first")
|
||||
ErrCrossesMidnight = errors.New("entry end_time must be on the same calendar day as start_time")
|
||||
)
|
||||
|
||||
// EntryService handles business logic for time entries.
|
||||
type EntryService struct {
|
||||
entries *store.EntryStore
|
||||
closedDays *store.ClosedDayStore
|
||||
settings *store.SettingsStore
|
||||
tz *time.Location
|
||||
}
|
||||
|
||||
func NewEntryService(
|
||||
entries *store.EntryStore,
|
||||
closedDays *store.ClosedDayStore,
|
||||
settings *store.SettingsStore,
|
||||
tz *time.Location,
|
||||
) *EntryService {
|
||||
return &EntryService{
|
||||
entries: entries,
|
||||
closedDays: closedDays,
|
||||
settings: settings,
|
||||
tz: tz,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *EntryService) nowMs() int64 {
|
||||
return time.Now().UnixMilli()
|
||||
}
|
||||
|
||||
func (s *EntryService) dayKeyForMs(ms int64) string {
|
||||
t := time.UnixMilli(ms).In(s.tz)
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
|
||||
// midnightEndMs returns the unix ms of 23:59:59.999 for the day containing ms in the configured tz.
|
||||
func (s *EntryService) midnightEndMs(ms int64) int64 {
|
||||
t := time.UnixMilli(ms).In(s.tz)
|
||||
end := time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 999_000_000, s.tz)
|
||||
return end.UnixMilli()
|
||||
}
|
||||
|
||||
// Start creates a new running entry. Returns ErrEntryRunning if one is already active.
|
||||
func (s *EntryService) Start(ctx context.Context, note string) (*domain.Entry, error) {
|
||||
running, err := s.entries.RunningEntry(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if running != nil {
|
||||
return nil, ErrEntryRunning
|
||||
}
|
||||
|
||||
nowMs := s.nowMs()
|
||||
dayKey := s.dayKeyForMs(nowMs)
|
||||
|
||||
// Check day is not already closed
|
||||
closed, err := s.closedDays.GetByDayKey(ctx, dayKey)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
if closed != nil {
|
||||
return nil, ErrDayAlreadyClosed
|
||||
}
|
||||
|
||||
e := &domain.Entry{
|
||||
ID: uuid.New().String(),
|
||||
StartTime: nowMs,
|
||||
Note: note,
|
||||
DayKey: dayKey,
|
||||
UpdatedAt: nowMs,
|
||||
}
|
||||
if err := s.entries.Create(ctx, e); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// Stop stops the currently running entry. Returns ErrEntryNotRunning if none active.
|
||||
func (s *EntryService) Stop(ctx context.Context) (*domain.Entry, error) {
|
||||
running, err := s.entries.RunningEntry(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if running == nil {
|
||||
return nil, ErrEntryNotRunning
|
||||
}
|
||||
return s.stopEntry(ctx, running, false)
|
||||
}
|
||||
|
||||
// StopByID stops a specific entry by ID.
|
||||
func (s *EntryService) StopByID(ctx context.Context, id string) (*domain.Entry, error) {
|
||||
e, err := s.entries.GetByID(ctx, id)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrEntryNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !e.IsRunning() {
|
||||
return nil, ErrEntryNotRunning
|
||||
}
|
||||
return s.stopEntry(ctx, e, false)
|
||||
}
|
||||
|
||||
func (s *EntryService) stopEntry(ctx context.Context, e *domain.Entry, autoStopped bool) (*domain.Entry, error) {
|
||||
nowMs := s.nowMs()
|
||||
|
||||
// Enforce same-day rule: cap end_time at 23:59:59.999 of start day
|
||||
endMs := nowMs
|
||||
startDayKey := s.dayKeyForMs(e.StartTime)
|
||||
endDayKey := s.dayKeyForMs(endMs)
|
||||
if endDayKey != startDayKey {
|
||||
// Cross-midnight: cap at end of start day
|
||||
endMs = s.midnightEndMs(e.StartTime)
|
||||
autoStopped = true
|
||||
}
|
||||
|
||||
e.EndTime = &endMs
|
||||
e.AutoStopped = autoStopped
|
||||
e.UpdatedAt = nowMs
|
||||
|
||||
if err := s.entries.Update(ctx, e); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// GetByID returns a single entry.
|
||||
func (s *EntryService) GetByID(ctx context.Context, id string) (*domain.Entry, error) {
|
||||
e, err := s.entries.GetByID(ctx, id)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrEntryNotFound
|
||||
}
|
||||
return e, err
|
||||
}
|
||||
|
||||
// List returns entries within a date range.
|
||||
func (s *EntryService) List(ctx context.Context, fromDayKey, toDayKey string) ([]*domain.Entry, error) {
|
||||
return s.entries.ListByDateRange(ctx, fromDayKey, toDayKey)
|
||||
}
|
||||
|
||||
// UpdateEntry allows editing start/end/note for a non-running, non-closed entry.
|
||||
type UpdateEntryInput struct {
|
||||
StartTime *int64
|
||||
EndTime *int64
|
||||
Note *string
|
||||
}
|
||||
|
||||
func (s *EntryService) Update(ctx context.Context, id string, input UpdateEntryInput) (*domain.Entry, error) {
|
||||
e, err := s.entries.GetByID(ctx, id)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrEntryNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if input.StartTime != nil {
|
||||
e.StartTime = *input.StartTime
|
||||
e.DayKey = s.dayKeyForMs(e.StartTime)
|
||||
}
|
||||
if input.EndTime != nil {
|
||||
// Validate same-day
|
||||
startDayKey := s.dayKeyForMs(e.StartTime)
|
||||
endDayKey := s.dayKeyForMs(*input.EndTime)
|
||||
if startDayKey != endDayKey {
|
||||
return nil, ErrCrossesMidnight
|
||||
}
|
||||
if *input.EndTime < e.StartTime {
|
||||
return nil, fmt.Errorf("end_time must be after start_time")
|
||||
}
|
||||
e.EndTime = input.EndTime
|
||||
}
|
||||
if input.Note != nil {
|
||||
e.Note = *input.Note
|
||||
}
|
||||
|
||||
e.UpdatedAt = s.nowMs()
|
||||
if err := s.entries.Update(ctx, e); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// Delete soft-deletes an entry.
|
||||
func (s *EntryService) Delete(ctx context.Context, id string) error {
|
||||
nowMs := s.nowMs()
|
||||
return s.entries.SoftDelete(ctx, id, nowMs)
|
||||
}
|
||||
|
||||
// AutoStopStalledEntries stops any running entries whose day_key is before today.
|
||||
// Called by the midnight background goroutine.
|
||||
func (s *EntryService) AutoStopStalledEntries(ctx context.Context) ([]string, error) {
|
||||
today := s.dayKeyForMs(s.nowMs())
|
||||
return s.entries.StopAllRunningBefore(ctx, today, s.nowMs(), s.nowMs())
|
||||
}
|
||||
136
internal/service/entry_service_test.go
Normal file
136
internal/service/entry_service_test.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package service_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/wotra/wotra/internal/service"
|
||||
"github.com/wotra/wotra/internal/store"
|
||||
)
|
||||
|
||||
func newTestServices(t *testing.T) *service.EntryService {
|
||||
t.Helper()
|
||||
db, err := store.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
entryStore := store.NewEntryStore(db)
|
||||
closedDayStore := store.NewClosedDayStore(db)
|
||||
settingsStore := store.NewSettingsStore(db)
|
||||
|
||||
tz, _ := time.LoadLocation("UTC")
|
||||
return service.NewEntryService(entryStore, closedDayStore, settingsStore, tz)
|
||||
}
|
||||
|
||||
func TestStartStop(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newTestServices(t)
|
||||
|
||||
entry, err := svc.Start(ctx, "test entry")
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if entry.ID == "" {
|
||||
t.Fatal("expected non-empty ID")
|
||||
}
|
||||
if entry.EndTime != nil {
|
||||
t.Fatal("expected running entry (nil EndTime)")
|
||||
}
|
||||
|
||||
stopped, err := svc.Stop(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
if stopped.EndTime == nil {
|
||||
t.Fatal("expected entry to be stopped")
|
||||
}
|
||||
if *stopped.EndTime < stopped.StartTime {
|
||||
t.Fatal("end_time before start_time")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartTwiceFails(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newTestServices(t)
|
||||
|
||||
if _, err := svc.Start(ctx, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := svc.Start(ctx, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error starting second entry")
|
||||
}
|
||||
if err != service.ErrEntryRunning {
|
||||
t.Fatalf("expected ErrEntryRunning, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopWithoutStart(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newTestServices(t)
|
||||
|
||||
_, err := svc.Stop(ctx)
|
||||
if err != service.ErrEntryNotRunning {
|
||||
t.Fatalf("expected ErrEntryNotRunning, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEntry(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newTestServices(t)
|
||||
|
||||
entry, _ := svc.Start(ctx, "initial note")
|
||||
stopped, _ := svc.Stop(ctx)
|
||||
|
||||
note := "updated note"
|
||||
updated, err := svc.Update(ctx, stopped.ID, service.UpdateEntryInput{Note: ¬e})
|
||||
if err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
if updated.Note != "updated note" {
|
||||
t.Errorf("expected 'updated note', got %q", updated.Note)
|
||||
}
|
||||
_ = entry
|
||||
}
|
||||
|
||||
func TestDeleteEntry(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newTestServices(t)
|
||||
|
||||
entry, _ := svc.Start(ctx, "")
|
||||
svc.Stop(ctx)
|
||||
|
||||
if err := svc.Delete(ctx, entry.ID); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
|
||||
entries, err := svc.List(ctx, "0000-01-01", "9999-12-31")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("expected 0 entries after delete, got %d", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListEntries(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newTestServices(t)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
svc.Start(ctx, "")
|
||||
svc.Stop(ctx)
|
||||
}
|
||||
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
entries, err := svc.List(ctx, today, today)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 3 {
|
||||
t.Fatalf("expected 3 entries, got %d", len(entries))
|
||||
}
|
||||
}
|
||||
61
internal/store/001_initial.sql
Normal file
61
internal/store/001_initial.sql
Normal file
@@ -0,0 +1,61 @@
|
||||
-- +migrate Up
|
||||
CREATE TABLE entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
start_time INTEGER NOT NULL,
|
||||
end_time INTEGER,
|
||||
auto_stopped INTEGER NOT NULL DEFAULT 0,
|
||||
note TEXT,
|
||||
day_key TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
deleted_at INTEGER
|
||||
);
|
||||
CREATE INDEX idx_entries_day ON entries(day_key);
|
||||
|
||||
CREATE TABLE closed_days (
|
||||
day_key TEXT PRIMARY KEY,
|
||||
start_time INTEGER,
|
||||
end_time INTEGER,
|
||||
worked_ms INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
closed_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE closed_weeks (
|
||||
week_key TEXT PRIMARY KEY,
|
||||
expected_ms INTEGER NOT NULL,
|
||||
worked_ms INTEGER NOT NULL,
|
||||
delta_ms INTEGER NOT NULL,
|
||||
closed_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE settings_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
effective_from TEXT NOT NULL,
|
||||
hours_per_week REAL NOT NULL,
|
||||
workdays_mask INTEGER NOT NULL DEFAULT 31,
|
||||
timezone TEXT NOT NULL DEFAULT 'UTC',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE sync_log (
|
||||
entity TEXT NOT NULL,
|
||||
entity_id TEXT NOT NULL,
|
||||
op TEXT NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
PRIMARY KEY (entity, entity_id, version)
|
||||
);
|
||||
|
||||
-- seed default settings
|
||||
INSERT INTO settings_history (effective_from, hours_per_week, workdays_mask, timezone, created_at)
|
||||
VALUES ('2000-01-01', 40.0, 31, 'UTC', unixepoch() * 1000);
|
||||
|
||||
-- +migrate Down
|
||||
DROP TABLE IF EXISTS sync_log;
|
||||
DROP TABLE IF EXISTS settings_history;
|
||||
DROP TABLE IF EXISTS closed_weeks;
|
||||
DROP TABLE IF EXISTS closed_days;
|
||||
DROP INDEX IF EXISTS idx_entries_day;
|
||||
DROP TABLE IF EXISTS entries;
|
||||
94
internal/store/closed_day_store.go
Normal file
94
internal/store/closed_day_store.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/wotra/wotra/internal/domain"
|
||||
)
|
||||
|
||||
// ClosedDayStore handles persistence for closed days.
|
||||
type ClosedDayStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewClosedDayStore(db *sql.DB) *ClosedDayStore {
|
||||
return &ClosedDayStore{db: db}
|
||||
}
|
||||
|
||||
func (s *ClosedDayStore) Upsert(ctx context.Context, d *domain.ClosedDay) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO closed_days (day_key, start_time, end_time, worked_ms, kind, closed_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(day_key) DO UPDATE SET
|
||||
start_time=excluded.start_time, end_time=excluded.end_time,
|
||||
worked_ms=excluded.worked_ms, kind=excluded.kind,
|
||||
closed_at=excluded.closed_at, updated_at=excluded.updated_at`,
|
||||
d.DayKey, d.StartTime, d.EndTime, d.WorkedMs, d.Kind, d.ClosedAt, d.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ClosedDayStore) Delete(ctx context.Context, dayKey string) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM closed_days WHERE day_key=?`, dayKey)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ClosedDayStore) GetByDayKey(ctx context.Context, dayKey string) (*domain.ClosedDay, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT day_key, start_time, end_time, worked_ms, kind, closed_at, updated_at
|
||||
FROM closed_days WHERE day_key=?`, dayKey)
|
||||
return scanClosedDay(row)
|
||||
}
|
||||
|
||||
func (s *ClosedDayStore) ListByDateRange(ctx context.Context, fromDayKey, toDayKey string) ([]*domain.ClosedDay, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT day_key, start_time, end_time, worked_ms, kind, closed_at, updated_at
|
||||
FROM closed_days WHERE day_key >= ? AND day_key <= ? ORDER BY day_key ASC`,
|
||||
fromDayKey, toDayKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []*domain.ClosedDay
|
||||
for rows.Next() {
|
||||
d, err := scanClosedDayRow(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, d)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func scanClosedDay(row *sql.Row) (*domain.ClosedDay, error) {
|
||||
var d domain.ClosedDay
|
||||
var startTime, endTime sql.NullInt64
|
||||
err := row.Scan(&d.DayKey, &startTime, &endTime, &d.WorkedMs, &d.Kind, &d.ClosedAt, &d.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if startTime.Valid {
|
||||
d.StartTime = &startTime.Int64
|
||||
}
|
||||
if endTime.Valid {
|
||||
d.EndTime = &endTime.Int64
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
func scanClosedDayRow(rows *sql.Rows) (*domain.ClosedDay, error) {
|
||||
var d domain.ClosedDay
|
||||
var startTime, endTime sql.NullInt64
|
||||
err := rows.Scan(&d.DayKey, &startTime, &endTime, &d.WorkedMs, &d.Kind, &d.ClosedAt, &d.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if startTime.Valid {
|
||||
d.StartTime = &startTime.Int64
|
||||
}
|
||||
if endTime.Valid {
|
||||
d.EndTime = &endTime.Int64
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
87
internal/store/closed_week_store.go
Normal file
87
internal/store/closed_week_store.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/wotra/wotra/internal/domain"
|
||||
)
|
||||
|
||||
// ClosedWeekStore handles persistence for closed weeks.
|
||||
type ClosedWeekStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewClosedWeekStore(db *sql.DB) *ClosedWeekStore {
|
||||
return &ClosedWeekStore{db: db}
|
||||
}
|
||||
|
||||
func (s *ClosedWeekStore) Upsert(ctx context.Context, w *domain.ClosedWeek) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO closed_weeks (week_key, expected_ms, worked_ms, delta_ms, closed_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(week_key) DO UPDATE SET
|
||||
expected_ms=excluded.expected_ms, worked_ms=excluded.worked_ms,
|
||||
delta_ms=excluded.delta_ms, closed_at=excluded.closed_at, updated_at=excluded.updated_at`,
|
||||
w.WeekKey, w.ExpectedMs, w.WorkedMs, w.DeltaMs, w.ClosedAt, w.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ClosedWeekStore) Delete(ctx context.Context, weekKey string) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM closed_weeks WHERE week_key=?`, weekKey)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ClosedWeekStore) GetByWeekKey(ctx context.Context, weekKey string) (*domain.ClosedWeek, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT week_key, expected_ms, worked_ms, delta_ms, closed_at, updated_at
|
||||
FROM closed_weeks WHERE week_key=?`, weekKey)
|
||||
var w domain.ClosedWeek
|
||||
err := row.Scan(&w.WeekKey, &w.ExpectedMs, &w.WorkedMs, &w.DeltaMs, &w.ClosedAt, &w.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &w, nil
|
||||
}
|
||||
|
||||
func (s *ClosedWeekStore) ListByRange(ctx context.Context, fromWeekKey, toWeekKey string) ([]*domain.ClosedWeek, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT week_key, expected_ms, worked_ms, delta_ms, closed_at, updated_at
|
||||
FROM closed_weeks WHERE week_key >= ? AND week_key <= ? ORDER BY week_key ASC`,
|
||||
fromWeekKey, toWeekKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []*domain.ClosedWeek
|
||||
for rows.Next() {
|
||||
var w domain.ClosedWeek
|
||||
if err := rows.Scan(&w.WeekKey, &w.ExpectedMs, &w.WorkedMs, &w.DeltaMs, &w.ClosedAt, &w.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, &w)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// SumWorkedMsForWeek sums worked_ms across closed_days for the given day keys.
|
||||
func SumWorkedMsForWeek(ctx context.Context, db *sql.DB, dayKeys []string) (int64, error) {
|
||||
if len(dayKeys) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
// Build IN clause
|
||||
placeholders := make([]byte, 0, len(dayKeys)*2)
|
||||
args := make([]interface{}, len(dayKeys))
|
||||
for i, k := range dayKeys {
|
||||
if i > 0 {
|
||||
placeholders = append(placeholders, ',')
|
||||
}
|
||||
placeholders = append(placeholders, '?')
|
||||
args[i] = k
|
||||
}
|
||||
query := "SELECT COALESCE(SUM(worked_ms),0) FROM closed_days WHERE day_key IN (" + string(placeholders) + ")"
|
||||
var total int64
|
||||
err := db.QueryRowContext(ctx, query, args...).Scan(&total)
|
||||
return total, err
|
||||
}
|
||||
91
internal/store/db.go
Normal file
91
internal/store/db.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
//go:embed 001_initial.sql
|
||||
var schema string
|
||||
|
||||
// Open opens (or creates) the SQLite database at path and runs migrations.
|
||||
func Open(path string) (*sql.DB, error) {
|
||||
dsn := fmt.Sprintf(
|
||||
"file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)&_pragma=synchronous(NORMAL)",
|
||||
path,
|
||||
)
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||
}
|
||||
db.SetMaxOpenConns(1) // SQLite WAL: single writer
|
||||
if err := migrate(db); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// migrate runs embedded SQL migrations. Simple single-file approach: we track
|
||||
// a user_version pragma and apply the schema once if version == 0.
|
||||
func migrate(db *sql.DB) error {
|
||||
var version int
|
||||
if err := db.QueryRow("PRAGMA user_version").Scan(&version); err != nil {
|
||||
return err
|
||||
}
|
||||
if version >= 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Strip migration comments and split into individual statements
|
||||
stmts := splitStatements(schema)
|
||||
for _, stmt := range stmts {
|
||||
stmt = strings.TrimSpace(stmt)
|
||||
if stmt == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, stmt); err != nil {
|
||||
return fmt.Errorf("exec %q: %w", stmt[:min(len(stmt), 60)], err)
|
||||
}
|
||||
}
|
||||
|
||||
// PRAGMA user_version cannot be set inside a regular transaction in all SQLite versions;
|
||||
// execute it as a standalone statement.
|
||||
if _, err := db.ExecContext(ctx, "PRAGMA user_version = 1"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func splitStatements(sql string) []string {
|
||||
// Only process statements up to the "-- +migrate Down" marker.
|
||||
var lines []string
|
||||
for _, line := range strings.Split(sql, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "-- +migrate Down" {
|
||||
break
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "-- +migrate") {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
joined := strings.Join(lines, "\n")
|
||||
// Split on semicolons
|
||||
parts := strings.Split(joined, ";")
|
||||
return parts
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
26
internal/store/db_test.go
Normal file
26
internal/store/db_test.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"github.com/wotra/wotra/internal/store"
|
||||
)
|
||||
|
||||
func TestMigration(t *testing.T) {
|
||||
db, err := store.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
tables := []string{"entries", "closed_days", "closed_weeks", "settings_history", "sync_log"}
|
||||
for _, tbl := range tables {
|
||||
var name string
|
||||
err := db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name=?", tbl).Scan(&name)
|
||||
if err == sql.ErrNoRows {
|
||||
t.Errorf("table %q not created", tbl)
|
||||
} else if err != nil {
|
||||
t.Errorf("query for %q: %v", tbl, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
174
internal/store/entry_store.go
Normal file
174
internal/store/entry_store.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"github.com/wotra/wotra/internal/domain"
|
||||
)
|
||||
|
||||
// EntryStore handles persistence for entries.
|
||||
type EntryStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewEntryStore(db *sql.DB) *EntryStore {
|
||||
return &EntryStore{db: db}
|
||||
}
|
||||
|
||||
func (s *EntryStore) Create(ctx context.Context, e *domain.Entry) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO entries (id, start_time, end_time, auto_stopped, note, day_key, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
e.ID, e.StartTime, e.EndTime, boolToInt(e.AutoStopped), e.Note, e.DayKey, e.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *EntryStore) Update(ctx context.Context, e *domain.Entry) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE entries SET start_time=?, end_time=?, auto_stopped=?, note=?, day_key=?, updated_at=?
|
||||
WHERE id=? AND deleted_at IS NULL`,
|
||||
e.StartTime, e.EndTime, boolToInt(e.AutoStopped), e.Note, e.DayKey, e.UpdatedAt, e.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *EntryStore) SoftDelete(ctx context.Context, id string, nowMs int64) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE entries SET deleted_at=?, updated_at=? WHERE id=? AND deleted_at IS NULL`,
|
||||
nowMs, nowMs, id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *EntryStore) GetByID(ctx context.Context, id string) (*domain.Entry, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, start_time, end_time, auto_stopped, note, day_key, updated_at, deleted_at
|
||||
FROM entries WHERE id=?`, id)
|
||||
return scanEntry(row)
|
||||
}
|
||||
|
||||
func (s *EntryStore) ListByDayKey(ctx context.Context, dayKey string) ([]*domain.Entry, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, start_time, end_time, auto_stopped, note, day_key, updated_at, deleted_at
|
||||
FROM entries WHERE day_key=? AND deleted_at IS NULL ORDER BY start_time ASC`, dayKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanEntries(rows)
|
||||
}
|
||||
|
||||
func (s *EntryStore) ListByDateRange(ctx context.Context, fromDayKey, toDayKey string) ([]*domain.Entry, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, start_time, end_time, auto_stopped, note, day_key, updated_at, deleted_at
|
||||
FROM entries WHERE day_key >= ? AND day_key <= ? AND deleted_at IS NULL ORDER BY start_time ASC`,
|
||||
fromDayKey, toDayKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanEntries(rows)
|
||||
}
|
||||
|
||||
// RunningEntry returns the currently running entry (no end_time), if any.
|
||||
func (s *EntryStore) RunningEntry(ctx context.Context) (*domain.Entry, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, start_time, end_time, auto_stopped, note, day_key, updated_at, deleted_at
|
||||
FROM entries WHERE end_time IS NULL AND deleted_at IS NULL LIMIT 1`)
|
||||
e, err := scanEntry(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return e, err
|
||||
}
|
||||
|
||||
// RunningEntryForDay returns a running entry for a specific day, if any.
|
||||
func (s *EntryStore) RunningEntryForDay(ctx context.Context, dayKey string) (*domain.Entry, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, start_time, end_time, auto_stopped, note, day_key, updated_at, deleted_at
|
||||
FROM entries WHERE day_key=? AND end_time IS NULL AND deleted_at IS NULL LIMIT 1`, dayKey)
|
||||
e, err := scanEntry(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return e, err
|
||||
}
|
||||
|
||||
// StopAllRunningBefore stops all entries whose day_key < dayKey with the given end time.
|
||||
// Used for the midnight auto-stop.
|
||||
func (s *EntryStore) StopAllRunningBefore(ctx context.Context, dayKey string, endTimeMs int64, nowMs int64) ([]string, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id FROM entries WHERE end_time IS NULL AND deleted_at IS NULL AND day_key < ?`, dayKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
rows.Close()
|
||||
for _, id := range ids {
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`UPDATE entries SET end_time=?, auto_stopped=1, updated_at=? WHERE id=?`,
|
||||
endTimeMs, nowMs, id); err != nil {
|
||||
return nil, fmt.Errorf("auto-stop %s: %w", id, err)
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func scanEntry(row *sql.Row) (*domain.Entry, error) {
|
||||
var e domain.Entry
|
||||
var endTime sql.NullInt64
|
||||
var deletedAt sql.NullInt64
|
||||
var autoStopped int
|
||||
err := row.Scan(&e.ID, &e.StartTime, &endTime, &autoStopped, &e.Note, &e.DayKey, &e.UpdatedAt, &deletedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if endTime.Valid {
|
||||
e.EndTime = &endTime.Int64
|
||||
}
|
||||
if deletedAt.Valid {
|
||||
e.DeletedAt = &deletedAt.Int64
|
||||
}
|
||||
e.AutoStopped = autoStopped != 0
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
func scanEntries(rows *sql.Rows) ([]*domain.Entry, error) {
|
||||
var result []*domain.Entry
|
||||
for rows.Next() {
|
||||
var e domain.Entry
|
||||
var endTime sql.NullInt64
|
||||
var deletedAt sql.NullInt64
|
||||
var autoStopped int
|
||||
if err := rows.Scan(&e.ID, &e.StartTime, &endTime, &autoStopped, &e.Note, &e.DayKey, &e.UpdatedAt, &deletedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if endTime.Valid {
|
||||
e.EndTime = &endTime.Int64
|
||||
}
|
||||
if deletedAt.Valid {
|
||||
e.DeletedAt = &deletedAt.Int64
|
||||
}
|
||||
e.AutoStopped = autoStopped != 0
|
||||
result = append(result, &e)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func boolToInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
81
internal/store/settings_store.go
Normal file
81
internal/store/settings_store.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/wotra/wotra/internal/domain"
|
||||
)
|
||||
|
||||
// SettingsStore handles persistence for settings history.
|
||||
type SettingsStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewSettingsStore(db *sql.DB) *SettingsStore {
|
||||
return &SettingsStore{db: db}
|
||||
}
|
||||
|
||||
// Current returns the most recent settings effective on or before the given day key.
|
||||
func (s *SettingsStore) Current(ctx context.Context, asOfDayKey string) (*domain.Settings, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, effective_from, hours_per_week, workdays_mask, timezone, created_at
|
||||
FROM settings_history
|
||||
WHERE effective_from <= ?
|
||||
ORDER BY effective_from DESC, id DESC
|
||||
LIMIT 1`, asOfDayKey)
|
||||
return scanSettings(row)
|
||||
}
|
||||
|
||||
// Latest returns the most recently created settings row.
|
||||
func (s *SettingsStore) Latest(ctx context.Context) (*domain.Settings, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, effective_from, hours_per_week, workdays_mask, timezone, created_at
|
||||
FROM settings_history
|
||||
ORDER BY effective_from DESC, id DESC
|
||||
LIMIT 1`)
|
||||
return scanSettings(row)
|
||||
}
|
||||
|
||||
// History returns all settings rows ordered by effective_from DESC.
|
||||
func (s *SettingsStore) History(ctx context.Context) ([]*domain.Settings, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, effective_from, hours_per_week, workdays_mask, timezone, created_at
|
||||
FROM settings_history ORDER BY effective_from DESC, id DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []*domain.Settings
|
||||
for rows.Next() {
|
||||
var s domain.Settings
|
||||
if err := rows.Scan(&s.ID, &s.EffectiveFrom, &s.HoursPerWeek, &s.WorkdaysMask, &s.Timezone, &s.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, &s)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// Insert inserts a new settings row.
|
||||
func (s *SettingsStore) Insert(ctx context.Context, set *domain.Settings) error {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO settings_history (effective_from, hours_per_week, workdays_mask, timezone, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
set.EffectiveFrom, set.HoursPerWeek, set.WorkdaysMask, set.Timezone, set.CreatedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
set.ID = id
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanSettings(row *sql.Row) (*domain.Settings, error) {
|
||||
var s domain.Settings
|
||||
err := row.Scan(&s.ID, &s.EffectiveFrom, &s.HoursPerWeek, &s.WorkdaysMask, &s.Timezone, &s.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
Reference in New Issue
Block a user