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:
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