feat: implement Havenllo single-board kanban (Go+SQLite backend, Preact/Vite frontend)
Some checks failed
Build and deploy Havenllo / Verify Go and frontend (push) Failing after 7s
Build and deploy Havenllo / Build and push image (push) Has been skipped
Build and deploy Havenllo / Apply and restart Havenllo (push) Has been skipped

- 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:
Hermes
2026-07-14 10:13:51 -03:00
commit 041c3fab4b
44 changed files with 5931 additions and 0 deletions

224
internal/api/handlers.go Normal file
View 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)
}

View 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
View 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
View 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
}