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:
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user