Files
verdande/internal/httpapi/realtime_handlers.go
T
Kristian WindandClaude Opus 5 dff86dd23d feat(api): projects, sections, tasks, quick add, views and live sync
Completes the MVP surface: project and section CRUD, task CRUD with
sub-trees, drag-and-drop ordering, the quick-add endpoint over the
natural-language parser, Today and Upcoming, search, sharing with roles,
an activity log and a WebSocket for live updates.

Permission checks are route middleware rather than a first line in each
handler, so an endpoint is guarded by where it sits instead of by the
author remembering. Anything the caller may not see answers 404, never
403 — a 403 confirms the thing exists. Visibility is enforced inside the
list query, so another user's project cannot be reached by filtering or
searching at it.

Ordering is a float, so a drop writes one row instead of renumbering the
list. When repeated drops into one gap exhaust the precision, the
section is spread out again and the move retried — in a separate
transaction, since the attempt that discovered the problem rolls back.

The connection pool is not 1: a single connection makes the pool a lock,
and a query run while an earlier result set is still open then waits for
a connection it is itself holding. The store now closes its rows before
querying again, and the pool leaves that mistake slow rather than fatal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:44:55 +02:00

133 lines
3.7 KiB
Go

package httpapi
import (
"context"
"net/http"
"time"
"github.com/coder/websocket"
"github.com/kristianwind/verdande/internal/realtime"
)
// activity records what happened, and never fails the request that caused it.
// A history that is missing an entry is a smaller problem than a rename that was
// refused because its history entry could not be written.
func (s *Server) activity(r *http.Request, projectID, taskID, event string, payload map[string]any) {
user := userFrom(r.Context())
if user == nil || projectID == "" {
return
}
if err := s.db.RecordActivity(r.Context(), projectID, taskID, user.ID, event, payload); err != nil {
s.log.Warn("record activity", "err", err, "event", event)
}
}
// publish pushes a change to everyone else looking at the project.
func (s *Server) publish(projectID, event string, payload any) {
s.hub.Publish(projectID, event, payload)
}
// handleWebSocket is the live-sync connection.
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
// The socket is same-origin only. Without this, any page on the internet
// could open a socket carrying the user's cookie and watch their tasks —
// the same-origin policy does not apply to WebSocket handshakes.
OriginPatterns: s.allowedOrigins(),
})
if err != nil {
s.log.Debug("websocket handshake", "err", err)
return
}
defer conn.CloseNow()
projects, err := s.db.ListProjects(r.Context(), user.ID, true)
if err != nil {
conn.Close(websocket.StatusInternalError, "could not load projects")
return
}
ids := make([]string, 0, len(projects))
for _, p := range projects {
ids = append(ids, p.ID)
}
client := s.hub.Register(user.ID, ids)
defer s.hub.Unregister(client)
// Detached from the request context: r.Context() is cancelled when the handler
// returns, and this connection has to outlive that.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Reading is what notices a closed connection, and it is also what keeps the
// pings flowing. Nothing a client sends is acted on — this stream is one-way
// by design, so there is no client message that can change server state.
go func() {
defer cancel()
for {
if _, _, err := conn.Read(ctx); err != nil {
return
}
}
}()
// A ping every 30 seconds keeps the connection alive through the idle timeouts
// of a reverse proxy — Cloudflare Tunnel closes a silent socket after 100
// seconds, which would otherwise look like the app randomly losing sync.
ping := time.NewTicker(30 * time.Second)
defer ping.Stop()
for {
select {
case <-ctx.Done():
return
case event, ok := <-client.Events():
if !ok {
conn.Close(websocket.StatusNormalClosure, "")
return
}
raw, err := realtime.Encode(event)
if err != nil {
continue
}
writeCtx, writeCancel := context.WithTimeout(ctx, 10*time.Second)
err = conn.Write(writeCtx, websocket.MessageText, raw)
writeCancel()
if err != nil {
return
}
case <-ping.C:
pingCtx, pingCancel := context.WithTimeout(ctx, 10*time.Second)
err := conn.Ping(pingCtx)
pingCancel()
if err != nil {
return
}
}
}
}
// allowedOrigins is the instance's own address, plus localhost when developing so
// the Vite dev server can connect.
func (s *Server) allowedOrigins() []string {
origins := []string{stripScheme(s.cfg.BaseURL)}
if s.cfg.Dev {
origins = append(origins, "localhost:*", "127.0.0.1:*")
}
return origins
}
func stripScheme(url string) string {
for _, prefix := range []string{"https://", "http://"} {
if len(url) > len(prefix) && url[:len(prefix)] == prefix {
return url[len(prefix):]
}
}
return url
}