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>
This commit is contained in:
Kristian Wind
2026-08-16 23:44:55 +02:00
co-authored by Claude Opus 5
parent 1bb644f4d1
commit dff86dd23d
15 changed files with 3667 additions and 5 deletions
+21
View File
@@ -15,6 +15,7 @@ import (
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
"github.com/kristianwind/verdande/internal/auth"
"github.com/kristianwind/verdande/internal/config"
"github.com/kristianwind/verdande/internal/store"
)
@@ -654,3 +655,23 @@ func codeFor(t *testing.T, secret string) string {
}
return code
}
// newJarClient gives a test its own cookie jar, which is what makes two clients
// against one server two different browsers rather than the same session twice.
func newJarClient(t *testing.T) *http.Client {
t.Helper()
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatal(err)
}
return &http.Client{Jar: jar, Timeout: 10 * time.Second}
}
func mustHash(t *testing.T, password string) string {
t.Helper()
hash, err := auth.HashPassword(password)
if err != nil {
t.Fatalf("hash password: %v", err)
}
return hash
}