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