feat: implement Havenllo single-board kanban (Go+SQLite backend, Preact/Vite frontend)
- Single fixed 'Haven' board: lists (To do/In progress/Done) + cards, no multi-board - Go 1.24 backend, CGO-free modernc.org/sqlite, embedded SPA, REST /api - Preact + Vite + TS frontend, native HTML5 drag-and-drop, optimistic UI, dark Haven theme - PVC (nfs-client) for SQLite, root container on NFS, simple nginx ingress havenllo.haven - Dockerfile multi-arch build, Gitea CI via pipeline-actions@main
This commit is contained in:
224
internal/api/handlers.go
Normal file
224
internal/api/handlers.go
Normal file
@@ -0,0 +1,224 @@
|
||||
// Package api exposes Havenllo's same-origin JSON API and embedded SPA handler.
|
||||
package api
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"havenllo/internal/app"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *app.Service
|
||||
static fs.FS
|
||||
}
|
||||
|
||||
func NewHandler(service *app.Service, static fs.FS) http.Handler {
|
||||
h := &Handler{service: service, static: static}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /api/health", h.health)
|
||||
mux.HandleFunc("GET /api/board", h.board)
|
||||
mux.HandleFunc("GET /api/lists", h.lists)
|
||||
mux.HandleFunc("POST /api/lists", h.createList)
|
||||
mux.HandleFunc("GET /api/lists/{id}", h.list)
|
||||
mux.HandleFunc("PATCH /api/lists/{id}", h.updateList)
|
||||
mux.HandleFunc("DELETE /api/lists/{id}", h.deleteList)
|
||||
mux.HandleFunc("GET /api/lists/{id}/cards", h.cards)
|
||||
mux.HandleFunc("POST /api/lists/{id}/cards", h.createCard)
|
||||
mux.HandleFunc("GET /api/cards/{id}", h.card)
|
||||
mux.HandleFunc("PATCH /api/cards/{id}", h.updateCard)
|
||||
mux.HandleFunc("DELETE /api/cards/{id}", h.deleteCard)
|
||||
mux.HandleFunc("/api/", h.apiNotFound)
|
||||
mux.HandleFunc("/", h.serveApp)
|
||||
return mux
|
||||
}
|
||||
|
||||
func (h *Handler) health(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.service.Health(r.Context()); err != nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "database is unavailable")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (h *Handler) board(w http.ResponseWriter, r *http.Request) {
|
||||
snapshot, err := h.service.Board(r.Context())
|
||||
if err != nil {
|
||||
writeDomainError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, snapshot)
|
||||
}
|
||||
|
||||
func (h *Handler) lists(w http.ResponseWriter, r *http.Request) {
|
||||
lists, err := h.service.Lists(r.Context())
|
||||
if err != nil {
|
||||
writeDomainError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, listsResponse{Lists: listSummaries(lists)})
|
||||
}
|
||||
|
||||
func (h *Handler) createList(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireJSON(w, r) {
|
||||
return
|
||||
}
|
||||
var request createListRequest
|
||||
if !decodeJSON(w, r, &request) {
|
||||
return
|
||||
}
|
||||
list, err := h.service.CreateList(r.Context(), request.Name, request.Position)
|
||||
if err != nil {
|
||||
writeDomainError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, listResponse{List: list})
|
||||
}
|
||||
|
||||
func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
list, err := h.service.List(r.Context(), id)
|
||||
if err != nil {
|
||||
writeDomainError(w, err)
|
||||
return
|
||||
}
|
||||
cards, err := h.service.Cards(r.Context(), id)
|
||||
if err != nil {
|
||||
writeDomainError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, listDetailsResponse{List: list, Cards: cards})
|
||||
}
|
||||
|
||||
func (h *Handler) updateList(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathID(w, r, "id")
|
||||
if !ok || !requireJSON(w, r) {
|
||||
return
|
||||
}
|
||||
var request updateListRequest
|
||||
if !decodeJSON(w, r, &request) {
|
||||
return
|
||||
}
|
||||
list, err := h.service.UpdateList(r.Context(), id, request.Name, request.Position)
|
||||
if err != nil {
|
||||
writeDomainError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, listResponse{List: list})
|
||||
}
|
||||
|
||||
func (h *Handler) deleteList(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.service.DeleteList(r.Context(), id); err != nil {
|
||||
writeDomainError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) cards(w http.ResponseWriter, r *http.Request) {
|
||||
listID, ok := pathID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
cards, err := h.service.Cards(r.Context(), listID)
|
||||
if err != nil {
|
||||
writeDomainError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, cardsResponse{Cards: cards})
|
||||
}
|
||||
|
||||
func (h *Handler) createCard(w http.ResponseWriter, r *http.Request) {
|
||||
listID, ok := pathID(w, r, "id")
|
||||
if !ok || !requireJSON(w, r) {
|
||||
return
|
||||
}
|
||||
var request createCardRequest
|
||||
if !decodeJSON(w, r, &request) {
|
||||
return
|
||||
}
|
||||
card, err := h.service.CreateCard(r.Context(), listID, request.Title, request.Description, request.Position)
|
||||
if err != nil {
|
||||
writeDomainError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, cardResponse{Card: card})
|
||||
}
|
||||
|
||||
func (h *Handler) card(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
card, err := h.service.Card(r.Context(), id)
|
||||
if err != nil {
|
||||
writeDomainError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, cardResponse{Card: card})
|
||||
}
|
||||
|
||||
func (h *Handler) updateCard(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathID(w, r, "id")
|
||||
if !ok || !requireJSON(w, r) {
|
||||
return
|
||||
}
|
||||
var request updateCardRequest
|
||||
if !decodeJSON(w, r, &request) {
|
||||
return
|
||||
}
|
||||
card, err := h.service.UpdateCard(r.Context(), id, app.CardPatch{
|
||||
Title: request.Title, Description: request.Description, Done: request.Done, ListID: request.ListID, Position: request.Position,
|
||||
})
|
||||
if err != nil {
|
||||
writeDomainError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, cardResponse{Card: card})
|
||||
}
|
||||
|
||||
func (h *Handler) deleteCard(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.service.DeleteCard(r.Context(), id); err != nil {
|
||||
writeDomainError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) apiNotFound(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "API endpoint not found")
|
||||
}
|
||||
|
||||
func (h *Handler) serveApp(w http.ResponseWriter, r *http.Request) {
|
||||
if h.static == nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "application bundle not found")
|
||||
return
|
||||
}
|
||||
requested := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
|
||||
if requested != "" {
|
||||
if _, err := fs.Stat(h.static, requested); err == nil {
|
||||
http.FileServer(http.FS(h.static)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
index, err := fs.ReadFile(h.static, "index.html")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "application bundle not found")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write(index)
|
||||
}
|
||||
86
internal/api/handlers_test.go
Normal file
86
internal/api/handlers_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"havenllo/internal/api"
|
||||
"havenllo/internal/app"
|
||||
"havenllo/internal/database"
|
||||
)
|
||||
|
||||
func newHandler(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
db, err := database.Open(context.Background(), filepath.Join(t.TempDir(), "havenllo.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
return api.NewHandler(app.New(db), fstest.MapFS{"index.html": {Data: []byte("<html></html>")}})
|
||||
}
|
||||
|
||||
func request(t *testing.T, handler http.Handler, method, path, body string, jsonBody bool) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
r := httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
||||
if jsonBody {
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestBoardAndHealth(t *testing.T) {
|
||||
t.Parallel()
|
||||
handler := newHandler(t)
|
||||
for _, path := range []string{"/api/health", "/api/board"} {
|
||||
response := request(t, handler, http.MethodGet, path, "", false)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("%s status = %d, body %s", path, response.Code, response.Body)
|
||||
}
|
||||
}
|
||||
var snapshot struct {
|
||||
Lists []json.RawMessage `json:"lists"`
|
||||
}
|
||||
if err := json.Unmarshal(request(t, handler, http.MethodGet, "/api/board", "", false).Body.Bytes(), &snapshot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(snapshot.Lists) != 3 {
|
||||
t.Fatalf("snapshot lists = %d, want 3", len(snapshot.Lists))
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONValidationAndLastListConflict(t *testing.T) {
|
||||
t.Parallel()
|
||||
handler := newHandler(t)
|
||||
if got := request(t, handler, http.MethodPost, "/api/lists", `{"name":"later","unknown":true}`, true); got.Code != http.StatusBadRequest {
|
||||
t.Fatalf("unknown field status = %d", got.Code)
|
||||
}
|
||||
if got := request(t, handler, http.MethodPost, "/api/lists", `{"name":"later"}`, false); got.Code != http.StatusUnsupportedMediaType {
|
||||
t.Fatalf("content type status = %d", got.Code)
|
||||
}
|
||||
response := request(t, handler, http.MethodGet, "/api/lists", "", false)
|
||||
var lists struct {
|
||||
Lists []struct {
|
||||
ID int64 `json:"id"`
|
||||
} `json:"lists"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &lists); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, list := range lists.Lists[:2] {
|
||||
if got := request(t, handler, http.MethodDelete, "/api/lists/"+strconv.FormatInt(list.ID, 10), "", false); got.Code != http.StatusNoContent {
|
||||
t.Fatalf("delete list status = %d", got.Code)
|
||||
}
|
||||
}
|
||||
if got := request(t, handler, http.MethodDelete, "/api/lists/"+strconv.FormatInt(lists.Lists[2].ID, 10), "", false); got.Code != http.StatusConflict {
|
||||
t.Fatalf("last list status = %d", got.Code)
|
||||
}
|
||||
}
|
||||
95
internal/api/http.go
Normal file
95
internal/api/http.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"havenllo/internal/domain"
|
||||
)
|
||||
|
||||
const maxJSONBody = 1 << 20
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, code, message string) {
|
||||
writeJSON(w, status, errorResponse{Error: apiError{Code: code, Message: message}})
|
||||
}
|
||||
|
||||
func writeDomainError(w http.ResponseWriter, err error) {
|
||||
var validation domain.ValidationError
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrNotFound):
|
||||
writeError(w, http.StatusNotFound, "not_found", "resource not found")
|
||||
case errors.Is(err, domain.ErrLastList):
|
||||
writeError(w, http.StatusConflict, "last_list", err.Error())
|
||||
case errors.As(err, &validation):
|
||||
writeError(w, http.StatusUnprocessableEntity, "validation_error", validation.Message)
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "an unexpected error occurred")
|
||||
}
|
||||
}
|
||||
|
||||
func requireJSON(w http.ResponseWriter, r *http.Request) bool {
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
mediaType, _, err := mime.ParseMediaType(contentType)
|
||||
if err != nil || mediaType != "application/json" {
|
||||
writeError(w, http.StatusUnsupportedMediaType, "unsupported_media_type", "Content-Type must be application/json")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxJSONBody)
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return false
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
writeError(w, http.StatusBadRequest, "invalid_json", "request body must contain one JSON value")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func writeDecodeError(w http.ResponseWriter, err error) {
|
||||
var syntaxError *json.SyntaxError
|
||||
var typeError *json.UnmarshalTypeError
|
||||
switch {
|
||||
case errors.As(err, &syntaxError):
|
||||
writeError(w, http.StatusBadRequest, "invalid_json", "request body contains malformed JSON")
|
||||
case errors.Is(err, io.ErrUnexpectedEOF):
|
||||
writeError(w, http.StatusBadRequest, "invalid_json", "request body contains malformed JSON")
|
||||
case errors.As(err, &typeError):
|
||||
writeError(w, http.StatusBadRequest, "invalid_json", fmt.Sprintf("invalid value for %s", typeError.Field))
|
||||
case strings.HasPrefix(err.Error(), "json: unknown field "):
|
||||
writeError(w, http.StatusBadRequest, "invalid_json", "request body contains an unknown field")
|
||||
case errors.Is(err, io.EOF):
|
||||
writeError(w, http.StatusBadRequest, "invalid_json", "request body must not be empty")
|
||||
case strings.Contains(err.Error(), "http: request body too large"):
|
||||
writeError(w, http.StatusBadRequest, "invalid_json", "request body is too large")
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "invalid_json", "request body is invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func pathID(w http.ResponseWriter, r *http.Request, key string) (int64, bool) {
|
||||
id, err := strconv.ParseInt(r.PathValue(key), 10, 64)
|
||||
if err != nil || id < 1 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_id", "resource ID must be a positive integer")
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
72
internal/api/types.go
Normal file
72
internal/api/types.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package api
|
||||
|
||||
import "havenllo/internal/domain"
|
||||
|
||||
type createListRequest struct {
|
||||
Name string `json:"name"`
|
||||
Position *float64 `json:"position,omitempty"`
|
||||
}
|
||||
|
||||
type updateListRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Position *float64 `json:"position,omitempty"`
|
||||
}
|
||||
|
||||
type createCardRequest struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Position *float64 `json:"position,omitempty"`
|
||||
}
|
||||
|
||||
type updateCardRequest struct {
|
||||
Title *string `json:"title,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Done *bool `json:"done,omitempty"`
|
||||
ListID *int64 `json:"list_id,omitempty"`
|
||||
Position *float64 `json:"position,omitempty"`
|
||||
}
|
||||
|
||||
type errorResponse struct {
|
||||
Error apiError `json:"error"`
|
||||
}
|
||||
|
||||
type apiError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type listResponse struct {
|
||||
List domain.List `json:"list"`
|
||||
}
|
||||
|
||||
type listDetailsResponse struct {
|
||||
List domain.List `json:"list"`
|
||||
Cards []domain.Card `json:"cards"`
|
||||
}
|
||||
|
||||
type listSummary struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Position float64 `json:"position"`
|
||||
CardCount int `json:"card_count"`
|
||||
}
|
||||
|
||||
type listsResponse struct {
|
||||
Lists []listSummary `json:"lists"`
|
||||
}
|
||||
type cardResponse struct {
|
||||
Card domain.Card `json:"card"`
|
||||
}
|
||||
type cardsResponse struct {
|
||||
Cards []domain.Card `json:"cards"`
|
||||
}
|
||||
|
||||
func listSummaries(lists []domain.List) []listSummary {
|
||||
summaries := make([]listSummary, 0, len(lists))
|
||||
for _, list := range lists {
|
||||
summaries = append(summaries, listSummary{
|
||||
ID: list.ID, Name: list.Name, Position: list.Position, CardCount: list.CardCount,
|
||||
})
|
||||
}
|
||||
return summaries
|
||||
}
|
||||
351
internal/app/service.go
Normal file
351
internal/app/service.go
Normal file
@@ -0,0 +1,351 @@
|
||||
// Package app contains the transactional business rules for the fixed Haven board.
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"havenllo/internal/domain"
|
||||
"havenllo/internal/store"
|
||||
)
|
||||
|
||||
const positionStep = 1024.0
|
||||
const minimumPositionGap = 0.0001
|
||||
|
||||
type Service struct{ db *sql.DB }
|
||||
|
||||
func New(db *sql.DB) *Service { return &Service{db: db} }
|
||||
|
||||
func (s *Service) Health(ctx context.Context) error { return s.db.PingContext(ctx) }
|
||||
|
||||
func (s *Service) Board(ctx context.Context) (domain.Snapshot, error) {
|
||||
return store.Snapshot(ctx, s.db)
|
||||
}
|
||||
|
||||
func (s *Service) Lists(ctx context.Context) ([]domain.List, error) {
|
||||
return store.Lists(ctx, s.db, true)
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, id int64) (domain.List, error) {
|
||||
return store.List(ctx, s.db, id)
|
||||
}
|
||||
|
||||
func (s *Service) Cards(ctx context.Context, listID int64) ([]domain.Card, error) {
|
||||
if _, err := store.List(ctx, s.db, listID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return store.Cards(ctx, s.db, listID)
|
||||
}
|
||||
|
||||
func (s *Service) Card(ctx context.Context, id int64) (domain.Card, error) {
|
||||
return store.Card(ctx, s.db, id)
|
||||
}
|
||||
|
||||
func (s *Service) CreateList(ctx context.Context, name string, position *float64) (domain.List, error) {
|
||||
name, err := validName(name)
|
||||
if err != nil {
|
||||
return domain.List{}, err
|
||||
}
|
||||
return withTx(ctx, s.db, func(tx *sql.Tx) (domain.List, error) {
|
||||
pos, err := positionForNewList(ctx, tx, position)
|
||||
if err != nil {
|
||||
return domain.List{}, err
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, "INSERT INTO lists (name, position) VALUES (?, ?)", name, pos)
|
||||
if err != nil {
|
||||
return domain.List{}, err
|
||||
}
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return domain.List{}, err
|
||||
}
|
||||
if err := normalizeLists(ctx, tx); err != nil {
|
||||
return domain.List{}, err
|
||||
}
|
||||
return store.List(ctx, tx, id)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) UpdateList(ctx context.Context, id int64, name *string, position *float64) (domain.List, error) {
|
||||
if name != nil {
|
||||
var err error
|
||||
if *name, err = validName(*name); err != nil {
|
||||
return domain.List{}, err
|
||||
}
|
||||
}
|
||||
if position != nil && !validPosition(*position) {
|
||||
return domain.List{}, domain.ValidationError{Message: "position must be a finite number"}
|
||||
}
|
||||
return withTx(ctx, s.db, func(tx *sql.Tx) (domain.List, error) {
|
||||
list, err := store.List(ctx, tx, id)
|
||||
if err != nil {
|
||||
return domain.List{}, err
|
||||
}
|
||||
if name != nil {
|
||||
list.Name = *name
|
||||
}
|
||||
if position != nil {
|
||||
list.Position = *position
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, "UPDATE lists SET name = ?, position = ? WHERE id = ?", list.Name, list.Position, id); err != nil {
|
||||
return domain.List{}, err
|
||||
}
|
||||
if err := normalizeLists(ctx, tx); err != nil {
|
||||
return domain.List{}, err
|
||||
}
|
||||
return store.List(ctx, tx, id)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) DeleteList(ctx context.Context, id int64) error {
|
||||
return withTxVoid(ctx, s.db, func(tx *sql.Tx) error {
|
||||
if _, err := store.List(ctx, tx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
count, err := store.CountLists(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count <= 1 {
|
||||
return domain.ErrLastList
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, "DELETE FROM lists WHERE id = ?", id)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) CreateCard(ctx context.Context, listID int64, title, description string, position *float64) (domain.Card, error) {
|
||||
title, err := validTitle(title)
|
||||
if err != nil {
|
||||
return domain.Card{}, err
|
||||
}
|
||||
if err := validDescription(description); err != nil {
|
||||
return domain.Card{}, err
|
||||
}
|
||||
return withTx(ctx, s.db, func(tx *sql.Tx) (domain.Card, error) {
|
||||
if _, err := store.List(ctx, tx, listID); err != nil {
|
||||
return domain.Card{}, err
|
||||
}
|
||||
pos, err := positionForNewCard(ctx, tx, listID, position)
|
||||
if err != nil {
|
||||
return domain.Card{}, err
|
||||
}
|
||||
now := timestamp()
|
||||
result, err := tx.ExecContext(ctx, `INSERT INTO cards (list_id, title, description, position, done, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 0, ?, ?)`, listID, title, description, pos, now, now)
|
||||
if err != nil {
|
||||
return domain.Card{}, err
|
||||
}
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return domain.Card{}, err
|
||||
}
|
||||
if err := normalizeCards(ctx, tx, listID); err != nil {
|
||||
return domain.Card{}, err
|
||||
}
|
||||
return store.Card(ctx, tx, id)
|
||||
})
|
||||
}
|
||||
|
||||
type CardPatch struct {
|
||||
Title *string
|
||||
Description *string
|
||||
Done *bool
|
||||
ListID *int64
|
||||
Position *float64
|
||||
}
|
||||
|
||||
func (s *Service) UpdateCard(ctx context.Context, id int64, patch CardPatch) (domain.Card, error) {
|
||||
if patch.Title != nil {
|
||||
var err error
|
||||
if *patch.Title, err = validTitle(*patch.Title); err != nil {
|
||||
return domain.Card{}, err
|
||||
}
|
||||
}
|
||||
if patch.Description != nil {
|
||||
if err := validDescription(*patch.Description); err != nil {
|
||||
return domain.Card{}, err
|
||||
}
|
||||
}
|
||||
if patch.Position != nil && !validPosition(*patch.Position) {
|
||||
return domain.Card{}, domain.ValidationError{Message: "position must be a finite number"}
|
||||
}
|
||||
if patch.ListID != nil && *patch.ListID < 1 {
|
||||
return domain.Card{}, domain.ValidationError{Message: "list_id must be a positive integer"}
|
||||
}
|
||||
return withTx(ctx, s.db, func(tx *sql.Tx) (domain.Card, error) {
|
||||
card, err := store.Card(ctx, tx, id)
|
||||
if err != nil {
|
||||
return domain.Card{}, err
|
||||
}
|
||||
sourceListID := card.ListID
|
||||
if patch.Title != nil {
|
||||
card.Title = *patch.Title
|
||||
}
|
||||
if patch.Description != nil {
|
||||
card.Description = *patch.Description
|
||||
}
|
||||
if patch.Done != nil {
|
||||
card.Done = *patch.Done
|
||||
}
|
||||
if patch.ListID != nil {
|
||||
if _, err := store.List(ctx, tx, *patch.ListID); err != nil {
|
||||
return domain.Card{}, err
|
||||
}
|
||||
card.ListID = *patch.ListID
|
||||
}
|
||||
if patch.Position != nil {
|
||||
card.Position = *patch.Position
|
||||
} else if card.ListID != sourceListID {
|
||||
pos, err := store.MaxCardPosition(ctx, tx, card.ListID)
|
||||
if err != nil {
|
||||
return domain.Card{}, err
|
||||
}
|
||||
card.Position = pos + positionStep
|
||||
}
|
||||
done := 0
|
||||
if card.Done {
|
||||
done = 1
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE cards SET list_id = ?, title = ?, description = ?, position = ?, done = ?, updated_at = ? WHERE id = ?`,
|
||||
card.ListID, card.Title, card.Description, card.Position, done, timestamp(), id); err != nil {
|
||||
return domain.Card{}, err
|
||||
}
|
||||
if err := normalizeCards(ctx, tx, sourceListID); err != nil {
|
||||
return domain.Card{}, err
|
||||
}
|
||||
if card.ListID != sourceListID {
|
||||
if err := normalizeCards(ctx, tx, card.ListID); err != nil {
|
||||
return domain.Card{}, err
|
||||
}
|
||||
}
|
||||
return store.Card(ctx, tx, id)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) DeleteCard(ctx context.Context, id int64) error {
|
||||
result, err := s.db.ExecContext(ctx, "DELETE FROM cards WHERE id = ?", id)
|
||||
return store.MustRows(result, err)
|
||||
}
|
||||
|
||||
func positionForNewList(ctx context.Context, q store.DBTX, requested *float64) (float64, error) {
|
||||
if requested != nil {
|
||||
if !validPosition(*requested) {
|
||||
return 0, domain.ValidationError{Message: "position must be a finite number"}
|
||||
}
|
||||
return *requested, nil
|
||||
}
|
||||
max, err := store.MaxListPosition(ctx, q)
|
||||
return max + positionStep, err
|
||||
}
|
||||
|
||||
func positionForNewCard(ctx context.Context, q store.DBTX, listID int64, requested *float64) (float64, error) {
|
||||
if requested != nil {
|
||||
if !validPosition(*requested) {
|
||||
return 0, domain.ValidationError{Message: "position must be a finite number"}
|
||||
}
|
||||
return *requested, nil
|
||||
}
|
||||
max, err := store.MaxCardPosition(ctx, q, listID)
|
||||
return max + positionStep, err
|
||||
}
|
||||
|
||||
func normalizeLists(ctx context.Context, tx *sql.Tx) error {
|
||||
lists, err := store.Lists(ctx, tx, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !needsNormalizationList(lists) {
|
||||
return nil
|
||||
}
|
||||
for i, list := range lists {
|
||||
if _, err := tx.ExecContext(ctx, "UPDATE lists SET position = ? WHERE id = ?", float64(i+1)*positionStep, list.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeCards(ctx context.Context, tx *sql.Tx, listID int64) error {
|
||||
cards, err := store.Cards(ctx, tx, listID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !needsNormalizationCards(cards) {
|
||||
return nil
|
||||
}
|
||||
for i, card := range cards {
|
||||
if _, err := tx.ExecContext(ctx, "UPDATE cards SET position = ? WHERE id = ?", float64(i+1)*positionStep, card.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func needsNormalizationList(lists []domain.List) bool {
|
||||
for i, list := range lists {
|
||||
if !validPosition(list.Position) || (i > 0 && list.Position-lists[i-1].Position < minimumPositionGap) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func needsNormalizationCards(cards []domain.Card) bool {
|
||||
for i, card := range cards {
|
||||
if !validPosition(card.Position) || (i > 0 && card.Position-cards[i-1].Position < minimumPositionGap) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validName(name string) (string, error) { return validTrimmed(name, 120, "name") }
|
||||
func validTitle(title string) (string, error) { return validTrimmed(title, 240, "title") }
|
||||
|
||||
func validTrimmed(value string, max int, field string) (string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "", domain.ValidationError{Message: field + " is required"}
|
||||
}
|
||||
if len([]rune(value)) > max {
|
||||
return "", domain.ValidationError{Message: fmt.Sprintf("%s must be at most %d characters", field, max)}
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func validDescription(description string) error {
|
||||
if len([]rune(description)) > 10000 {
|
||||
return domain.ValidationError{Message: "description must be at most 10000 characters"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validPosition(position float64) bool { return !math.IsNaN(position) && !math.IsInf(position, 0) }
|
||||
func timestamp() string { return time.Now().UTC().Format(time.RFC3339Nano) }
|
||||
|
||||
func withTx[T any](ctx context.Context, db *sql.DB, run func(*sql.Tx) (T, error)) (T, error) {
|
||||
var zero T
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
value, err := run(tx)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return zero, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return zero, err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func withTxVoid(ctx context.Context, db *sql.DB, run func(*sql.Tx) error) error {
|
||||
_, err := withTx(ctx, db, func(tx *sql.Tx) (struct{}, error) { return struct{}{}, run(tx) })
|
||||
return err
|
||||
}
|
||||
97
internal/app/service_test.go
Normal file
97
internal/app/service_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package app_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"havenllo/internal/app"
|
||||
"havenllo/internal/database"
|
||||
"havenllo/internal/domain"
|
||||
)
|
||||
|
||||
func newService(t *testing.T) (*app.Service, func()) {
|
||||
t.Helper()
|
||||
db, err := database.Open(context.Background(), filepath.Join(t.TempDir(), "havenllo.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return app.New(db), func() { _ = db.Close() }
|
||||
}
|
||||
|
||||
func TestLastListProtection(t *testing.T) {
|
||||
t.Parallel()
|
||||
service, closeDB := newService(t)
|
||||
defer closeDB()
|
||||
ctx := context.Background()
|
||||
lists, err := service.Lists(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, list := range lists[:2] {
|
||||
if err := service.DeleteList(ctx, list.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := service.DeleteList(ctx, lists[2].ID); !errors.Is(err, domain.ErrLastList) {
|
||||
t.Fatalf("DeleteList error = %v, want ErrLastList", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardMoveNormalizesCrowdedPositions(t *testing.T) {
|
||||
t.Parallel()
|
||||
service, closeDB := newService(t)
|
||||
defer closeDB()
|
||||
ctx := context.Background()
|
||||
lists, err := service.Lists(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := lists[0]
|
||||
a, err := service.CreateCard(ctx, first.ID, "a", "", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := service.CreateCard(ctx, first.ID, "b", "", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
position := a.Position + 0.00001
|
||||
if _, err := service.UpdateCard(ctx, b.ID, app.CardPatch{Position: &position}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cards, err := service.Cards(ctx, first.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cards) != 2 || cards[1].Position-cards[0].Position < 0.0001 {
|
||||
t.Fatalf("cards were not normalized: %#v", cards)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardMoveAndCascade(t *testing.T) {
|
||||
t.Parallel()
|
||||
service, closeDB := newService(t)
|
||||
defer closeDB()
|
||||
ctx := context.Background()
|
||||
lists, _ := service.Lists(ctx)
|
||||
card, err := service.CreateCard(ctx, lists[0].ID, "move me", "", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := lists[1].ID
|
||||
moved, err := service.UpdateCard(ctx, card.ID, app.CardPatch{ListID: &target})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if moved.ListID != target {
|
||||
t.Fatalf("card list = %d, want %d", moved.ListID, target)
|
||||
}
|
||||
if err := service.DeleteList(ctx, target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.Card(ctx, card.ID); !errors.Is(err, domain.ErrNotFound) {
|
||||
t.Fatalf("card after list deletion = %v, want not found", err)
|
||||
}
|
||||
}
|
||||
37
internal/database/database.go
Normal file
37
internal/database/database.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// Open configures the one SQLite connection used by the single-replica app and
|
||||
// migrates it before returning it to callers.
|
||||
func Open(ctx context.Context, path string) (*sql.DB, error) {
|
||||
dir := filepath.Dir(path)
|
||||
if dir != "." && dir != "" {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create database directory: %w", err)
|
||||
}
|
||||
}
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
if _, err := db.ExecContext(ctx, "PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000; PRAGMA journal_mode = WAL;"); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("configure database: %w", err)
|
||||
}
|
||||
if err := migrate(ctx, db); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
69
internal/database/database_test.go
Normal file
69
internal/database/database_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package database_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"havenllo/internal/database"
|
||||
)
|
||||
|
||||
func TestOpenMigratesSeedsAndEnforcesCascade(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "havenllo.db")
|
||||
db, err := database.Open(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
var count int
|
||||
if err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM lists").Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("seeded lists = %d, want 3", count)
|
||||
}
|
||||
var listID int64
|
||||
if err := db.QueryRowContext(ctx, "SELECT id FROM lists ORDER BY position LIMIT 1").Scan(&listID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO cards (list_id, title, description, position, done, created_at, updated_at)
|
||||
VALUES (?, 'test', '', 1024, 0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')`, listID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, "DELETE FROM lists WHERE id = ?", listID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM cards").Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("cascade left %d cards", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenIsIdempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "havenllo.db")
|
||||
db, err := database.Open(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = db.Close()
|
||||
db, err = database.Open(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
var count int
|
||||
if err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM schema_migrations").Scan(&count); err != nil && err != sql.ErrNoRows {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("migrations = %d, want 1", count)
|
||||
}
|
||||
}
|
||||
87
internal/database/migrations.go
Normal file
87
internal/database/migrations.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type migration struct {
|
||||
version int
|
||||
apply func(context.Context, *sql.Tx) error
|
||||
}
|
||||
|
||||
var migrations = []migration{
|
||||
{version: 1, apply: migrationOne},
|
||||
}
|
||||
|
||||
func migrate(ctx context.Context, db *sql.DB) error {
|
||||
if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL
|
||||
)`); err != nil {
|
||||
return fmt.Errorf("create migrations table: %w", err)
|
||||
}
|
||||
|
||||
for _, m := range migrations {
|
||||
var exists int
|
||||
if err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM schema_migrations WHERE version = ?", m.version).Scan(&exists); err != nil {
|
||||
return fmt.Errorf("read migration %d: %w", m.version, err)
|
||||
}
|
||||
if exists != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin migration %d: %w", m.version, err)
|
||||
}
|
||||
if err := m.apply(ctx, tx); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("apply migration %d: %w", m.version, err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, "INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)", m.version, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("record migration %d: %w", m.version, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit migration %d: %w", m.version, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrationOne(ctx context.Context, tx *sql.Tx) error {
|
||||
statements := []string{
|
||||
`CREATE TABLE lists (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL CHECK (length(trim(name)) BETWEEN 1 AND 120),
|
||||
position REAL NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE cards (
|
||||
id INTEGER PRIMARY KEY,
|
||||
list_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL CHECK (length(trim(title)) BETWEEN 1 AND 240),
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
position REAL NOT NULL,
|
||||
done INTEGER NOT NULL DEFAULT 0 CHECK (done IN (0, 1)),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (list_id) REFERENCES lists(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE INDEX idx_lists_position ON lists(position, id)`,
|
||||
`CREATE INDEX idx_cards_list_position ON cards(list_id, position, id)`,
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if _, err := tx.ExecContext(ctx, statement); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for i, name := range []string{"To do", "In progress", "Done"} {
|
||||
if _, err := tx.ExecContext(ctx, "INSERT INTO lists (name, position) VALUES (?, ?)", name, float64(i+1)*1024); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
42
internal/domain/models.go
Normal file
42
internal/domain/models.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package domain
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("resource not found")
|
||||
ErrLastList = errors.New("the last remaining list cannot be deleted")
|
||||
)
|
||||
|
||||
// ValidationError represents a user-correctable domain validation failure.
|
||||
type ValidationError struct{ Message string }
|
||||
|
||||
func (e ValidationError) Error() string { return e.Message }
|
||||
|
||||
type List struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Position float64 `json:"position"`
|
||||
CardCount int `json:"card_count,omitempty"`
|
||||
}
|
||||
|
||||
type Card struct {
|
||||
ID int64 `json:"id"`
|
||||
ListID int64 `json:"list_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Position float64 `json:"position"`
|
||||
Done bool `json:"done"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ListWithCards struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Position float64 `json:"position"`
|
||||
Cards []Card `json:"cards"`
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
Lists []ListWithCards `json:"lists"`
|
||||
}
|
||||
146
internal/store/sqlite.go
Normal file
146
internal/store/sqlite.go
Normal file
@@ -0,0 +1,146 @@
|
||||
// Package store contains small, parameterized SQLite queries used by the service.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"havenllo/internal/domain"
|
||||
)
|
||||
|
||||
type DBTX interface {
|
||||
ExecContext(context.Context, string, ...any) (sql.Result, error)
|
||||
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
|
||||
QueryRowContext(context.Context, string, ...any) *sql.Row
|
||||
}
|
||||
|
||||
func Lists(ctx context.Context, q DBTX, counts bool) ([]domain.List, error) {
|
||||
query := `SELECT l.id, l.name, l.position`
|
||||
if counts {
|
||||
query += `, COUNT(c.id) FROM lists l LEFT JOIN cards c ON c.list_id = l.id GROUP BY l.id, l.name, l.position ORDER BY l.position, l.id`
|
||||
} else {
|
||||
query += ` FROM lists l ORDER BY l.position, l.id`
|
||||
}
|
||||
rows, err := q.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
lists := make([]domain.List, 0)
|
||||
for rows.Next() {
|
||||
var list domain.List
|
||||
if counts {
|
||||
err = rows.Scan(&list.ID, &list.Name, &list.Position, &list.CardCount)
|
||||
} else {
|
||||
err = rows.Scan(&list.ID, &list.Name, &list.Position)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lists = append(lists, list)
|
||||
}
|
||||
return lists, rows.Err()
|
||||
}
|
||||
|
||||
func List(ctx context.Context, q DBTX, id int64) (domain.List, error) {
|
||||
var list domain.List
|
||||
err := q.QueryRowContext(ctx, "SELECT id, name, position FROM lists WHERE id = ?", id).Scan(&list.ID, &list.Name, &list.Position)
|
||||
if err == sql.ErrNoRows {
|
||||
return domain.List{}, domain.ErrNotFound
|
||||
}
|
||||
return list, err
|
||||
}
|
||||
|
||||
func Cards(ctx context.Context, q DBTX, listID int64) ([]domain.Card, error) {
|
||||
rows, err := q.QueryContext(ctx, `SELECT id, list_id, title, description, position, done, created_at, updated_at
|
||||
FROM cards WHERE list_id = ? ORDER BY position, id`, listID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
cards := make([]domain.Card, 0)
|
||||
for rows.Next() {
|
||||
card, err := scanCard(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cards = append(cards, card)
|
||||
}
|
||||
return cards, rows.Err()
|
||||
}
|
||||
|
||||
func Card(ctx context.Context, q DBTX, id int64) (domain.Card, error) {
|
||||
row := q.QueryRowContext(ctx, `SELECT id, list_id, title, description, position, done, created_at, updated_at
|
||||
FROM cards WHERE id = ?`, id)
|
||||
card, err := scanCard(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return domain.Card{}, domain.ErrNotFound
|
||||
}
|
||||
return card, err
|
||||
}
|
||||
|
||||
type scanner interface{ Scan(...any) error }
|
||||
|
||||
func scanCard(row scanner) (domain.Card, error) {
|
||||
var card domain.Card
|
||||
var done int
|
||||
err := row.Scan(&card.ID, &card.ListID, &card.Title, &card.Description, &card.Position, &done, &card.CreatedAt, &card.UpdatedAt)
|
||||
card.Done = done != 0
|
||||
return card, err
|
||||
}
|
||||
|
||||
func Snapshot(ctx context.Context, q DBTX) (domain.Snapshot, error) {
|
||||
lists, err := Lists(ctx, q, false)
|
||||
if err != nil {
|
||||
return domain.Snapshot{}, err
|
||||
}
|
||||
snapshot := domain.Snapshot{Lists: make([]domain.ListWithCards, 0, len(lists))}
|
||||
for _, list := range lists {
|
||||
cards, err := Cards(ctx, q, list.ID)
|
||||
if err != nil {
|
||||
return domain.Snapshot{}, err
|
||||
}
|
||||
snapshot.Lists = append(snapshot.Lists, domain.ListWithCards{ID: list.ID, Name: list.Name, Position: list.Position, Cards: cards})
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func MaxListPosition(ctx context.Context, q DBTX) (float64, error) {
|
||||
var position float64
|
||||
err := q.QueryRowContext(ctx, "SELECT COALESCE(MAX(position), 0) FROM lists").Scan(&position)
|
||||
return position, err
|
||||
}
|
||||
|
||||
func MaxCardPosition(ctx context.Context, q DBTX, listID int64) (float64, error) {
|
||||
var position float64
|
||||
err := q.QueryRowContext(ctx, "SELECT COALESCE(MAX(position), 0) FROM cards WHERE list_id = ?", listID).Scan(&position)
|
||||
return position, err
|
||||
}
|
||||
|
||||
func CountLists(ctx context.Context, q DBTX) (int, error) {
|
||||
var count int
|
||||
err := q.QueryRowContext(ctx, "SELECT COUNT(*) FROM lists").Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
func MustRows(result sql.Result, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Wrap(action string, err error) error {
|
||||
if err == nil || err == domain.ErrNotFound {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("%s: %w", action, err)
|
||||
}
|
||||
29
internal/store/sqlite_test.go
Normal file
29
internal/store/sqlite_test.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"havenllo/internal/database"
|
||||
"havenllo/internal/store"
|
||||
)
|
||||
|
||||
func TestSnapshotUsesPositionOrder(t *testing.T) {
|
||||
t.Parallel()
|
||||
db, err := database.Open(context.Background(), filepath.Join(t.TempDir(), "havenllo.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.Exec("INSERT INTO lists (name, position) VALUES ('First', 1), ('Last', 9999)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot, err := store.Snapshot(context.Background(), db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(snapshot.Lists) != 5 || snapshot.Lists[0].Name != "First" || snapshot.Lists[len(snapshot.Lists)-1].Name != "Last" {
|
||||
t.Fatalf("unexpected order: %#v", snapshot.Lists)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user