Files
brain/internal/handlers/kanban.go
T
Andrew S Erwin 4e421f6ea2
release gate / check (push) Failing after 6m43s
Address code review findings across API, web and deployment
High severity:
- wire TRUSTED_PROXY through Compose/deploy/docs and warn at web startup
- bucket public rate limits on effective client IP, add per-bookmark budget,
  and forward the signed browser identity from the share page
- disable first-run bootstrap in server deployments and document seeding
- serialize last-admin demotion in one advisory-locked transaction

Medium/low:
- cap and batch share recipient inserts
- add a shared outbound budget for preview/CardDAV/IMAP/signal work
- pin CardDAV dials to 443 and bound IMAP logout
- fix rightward kanban column drags and normalize cards on column delete
- classify backend outages in auth guards, add request timeouts and an
  error boundary, remove the dead login route, surface throttling
- enforce pinned/unpinned page reorder grouping server-side
- avoid preview work for stale bookmark versions; drop duplicate dialog fetch
- sequence-guard collection/tag/calendar/quick-find loads
- validate property config and fix MULTI_SELECT/DATE filter inputs
- document index maintenance; apply same-origin check to sign-out
- trim signup names, unique shared dates and kanban keys (migration 010),
  reset reference cache on identity changes, hide OIDC issuer, return 413
  for oversized direct bodies, escape Markdown link destinations
- remove dead template files and duplicate test helper

Verified with go build/vet, full PostgreSQL race suite, db convergence,
tsc, both ESLint configs and a Next production build.
2026-10-20 00:13:30 -05:00

299 lines
9.8 KiB
Go

package handlers
import (
"database/sql"
"github.com/andrew/go-computer/internal/db"
"github.com/andrew/go-computer/internal/kanban"
"net/http"
"strings"
)
func HandleKanbanColumnsList(w http.ResponseWriter, r *http.Request) {
v, e := kanban.GetColumns(r.Context(), database(r), user(r))
respond(w, 200, v, e)
}
func columnData(b object) (object, error) {
d := fields(b, "label color")
if e := stringFields(d, "", "label color"); e != nil {
return nil, e
}
if _, ok := d["label"]; ok {
s := strings.TrimSpace(str(d, "label"))
if s == "" {
return nil, bad("Label required")
}
r := []rune(s)
if len(r) > 40 {
r = r[:40]
}
d["label"] = string(r)
}
return d, validColor(d)
}
func HandleKanbanColumnsCreate(w http.ResponseWriter, r *http.Request) {
b, ok := read(w, r)
if !ok {
return
}
d, e := columnData(b)
if e != nil {
respond(w, 0, nil, e)
return
}
if str(d, "label") == "" {
respond(w, 0, nil, bad("Label required"))
return
}
if _, ok := d["color"]; !ok {
d["color"] = "#8b5cf6"
}
d["id"], d["userId"] = db.NewID(), user(r)
var v object
e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
if e := lockUser(r, tx); e != nil {
return e
}
var n int
if e := tx.QueryRowContext(r.Context(), `SELECT COALESCE(max(position),-1)+1 FROM "KanbanColumn" WHERE "userId"=$1`, user(r)).Scan(&n); e != nil {
return e
}
d["position"] = n
var e error
v, e = db.Insert(r.Context(), tx, "KanbanColumn", d)
return e
})
respond(w, 201, v, e)
}
func HandleKanbanColumnsReorder(w http.ResponseWriter, r *http.Request) {
b, ok := read(w, r)
if !ok {
return
}
order, e := ids(b, "order")
if e == nil {
e = reorder(r, "KanbanColumn", "userId", order)
}
if e != nil {
respond(w, 0, nil, e)
return
}
HandleKanbanColumnsList(w, r)
}
func HandleKanbanColumnsUpdate(w http.ResponseWriter, r *http.Request) {
b, ok := read(w, r)
if !ok {
return
}
d, e := columnData(b)
if e != nil {
respond(w, 0, nil, e)
return
}
v, e := db.Update(r.Context(), database(r), "KanbanColumn", r.PathValue("id"), "userId", user(r), d)
respond(w, 200, v, e)
}
// HandleKanbanCardsMove moves or reorders one card on the board. It carries
// the same optimistic-concurrency version as a bookmark PUT (review CR-04):
// a card edited or moved elsewhere is a 409, and the board reloads instead of
// silently clobbering it. The target column's positions are normalized in the
// same transaction, so dragging inside a column survives reloads.
func HandleKanbanCardsMove(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
b, ok := read(w, r)
if !ok {
return
}
columnID := strings.TrimSpace(str(b, "columnId"))
if columnID == "" {
respond(w, 0, nil, bad("columnId is required"))
return
}
rawPosition, hasPosition := b["position"]
if !hasPosition {
respond(w, 0, nil, bad("position is required"))
return
}
f, ok := rawPosition.(float64)
if !ok || f < 0 || f != float64(int64(f)) {
respond(w, 0, nil, bad("position must be a non-negative integer"))
return
}
position := int(f)
baseVersion, hasVersion := 0, false
if v, present := b["version"]; present {
f, isNum := v.(float64)
if !isNum || f < 0 || f != float64(int64(f)) {
respond(w, 0, nil, bad("version must be a non-negative integer"))
return
}
baseVersion, hasVersion = int(f), true
}
if !hasVersion {
respond(w, 0, nil, bad("version is required when moving a card; refetch it and retry"))
return
}
// Ensure the default board exists before the transaction reads columns.
if _, e := kanban.GetColumns(r.Context(), database(r), user(r)); e != nil {
respond(w, 0, nil, e)
return
}
e := db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
if e := lockUser(r, tx); e != nil {
return e
}
cur, e := owned(r, tx, "Bookmark", id, "ownerId")
if e != nil {
return e
}
if stored, _ := cur["version"].(float64); int(stored) != baseVersion {
return conflict("Bookmark was modified elsewhere; reload to see the latest version")
}
if _, e := owned(r, tx, "KanbanColumn", columnID, "userId"); e != nil {
return bad("Invalid columnId")
}
cols, e := db.Many(r.Context(), tx, `SELECT to_jsonb(c) FROM "KanbanColumn" c WHERE "userId"=$1 ORDER BY position,id`, user(r))
if e != nil {
return e
}
// Resolve legacy cards (kanbanStatus only) the same way the board does,
// then normalize every card in the target column to explicit
// membership plus a contiguous position. That makes the new order
// durable without requiring a schema backfill.
rows, e := db.Many(r.Context(), tx, `SELECT jsonb_build_object('id',b.id,'kanbanColumnId',b."kanbanColumnId",'kanbanStatus',b."kanbanStatus") FROM "Bookmark" b WHERE b."ownerId"=$1 ORDER BY b."kanbanPosition",b."updatedAt" DESC,b.id DESC`, user(r))
if e != nil {
return e
}
target := make([]string, 0, len(rows))
for _, row := range rows {
if str(row, "id") == id {
continue
}
if kanban.ResolveColumnID(row, cols) == columnID {
target = append(target, str(row, "id"))
}
}
if position > len(target) {
position = len(target)
}
target = append(target, "")
copy(target[position+1:], target[position:])
target[position] = id
if _, e := tx.ExecContext(r.Context(), `UPDATE "Bookmark" AS b SET "kanbanColumnId"=$1,"kanbanPosition"=t.ord-1 FROM unnest($2::text[]) WITH ORDINALITY AS t(bid,ord) WHERE b.id=t.bid AND b."ownerId"=$3`, columnID, target, user(r)); e != nil {
return e
}
// Only the moved card's revision advances; reindexing its neighbours is
// board placement, not a content edit.
if _, e := tx.ExecContext(r.Context(), `UPDATE "Bookmark" SET version=version+1,"updatedAt"=now() WHERE id=$1 AND "ownerId"=$2`, id, user(r)); e != nil {
return e
}
return nil
})
if e != nil {
respond(w, 0, nil, e)
return
}
v, e := bookmark(r, id, false)
respond(w, 200, v, e)
}
func HandleKanbanColumnsDelete(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var dest string
var moved int
e := db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
// The user row is the single serialization point for board mutations
// (review CR-05): bookmark create/update take the same lock before
// validating/assigning kanbanColumnId, so this handler's snapshot of
// affected bookmarks, the move and the column delete cannot interleave
// with a competing assignment.
if e := lockUser(r, tx); e != nil {
return e
}
cols, e := db.Many(r.Context(), tx, `SELECT to_jsonb(c) FROM "KanbanColumn" c WHERE "userId"=$1 ORDER BY position,id`, user(r))
if e != nil {
return e
}
found := false
remaining := []object{}
for _, c := range cols {
if str(c, "id") == id {
found = true
} else {
remaining = append(remaining, c)
}
}
if !found {
return db.ErrNotFound
}
if len(remaining) == 0 {
return bad("A board needs at least one column")
}
dest = str(remaining[0], "id")
// The destination column's legacy key, when it has one, becomes the
// moved cards' kanbanStatus so the two representations do not point
// at the deleted column (review M-5/L-12). Custom columns have no
// key; their cards keep the legacy status as a documented fallback.
destKey := ""
for _, c := range remaining {
if str(c, "id") == dest {
if k := str(c, "key"); validStatus(k) {
destKey = k
}
}
}
// Only the three columns ResolveColumnID needs, and one set-based
// UPDATE for the whole move instead of a round trip per bookmark.
// Positions are normalized below too (review M-5), so reload order is
// deterministic instead of interleaving with the destination's cards.
bms, e := db.Many(r.Context(), tx, `SELECT jsonb_build_object('id',b.id,'kanbanColumnId',b."kanbanColumnId",'kanbanStatus',b."kanbanStatus") FROM "Bookmark" b WHERE "ownerId"=$1 ORDER BY b."kanbanPosition",b."updatedAt" DESC,b.id DESC`, user(r))
if e != nil {
return e
}
moveIDs := make([]string, 0, len(bms))
moving := map[string]bool{}
for _, b := range bms {
if kanban.ResolveColumnID(b, cols) == id {
moveIDs = append(moveIDs, str(b, "id"))
moving[str(b, "id")] = true
}
}
if len(moveIDs) > 0 {
// The move is a mutation covered by the bookmark concurrency
// contract (review CR-04): bumping version turns any client
// snapshot that still shows the old column into a 409 on its
// next save instead of letting it silently move the bookmark
// back.
if _, e := tx.ExecContext(r.Context(), `UPDATE "Bookmark" SET "kanbanColumnId"=$1,"kanbanStatus"=CASE WHEN $4='' THEN "kanbanStatus" ELSE $4::"KanbanStatus" END,version=version+1,"updatedAt"=now() WHERE id=ANY($2::text[]) AND "ownerId"=$3`, dest, moveIDs, user(r), destKey); e != nil {
return e
}
// Append the moved cards after the destination's existing order
// and renumber contiguously, exactly like a normal card move.
order := make([]string, 0, len(bms))
for _, b := range bms {
bid := str(b, "id")
if !moving[bid] && kanban.ResolveColumnID(b, remaining) == dest {
order = append(order, bid)
}
}
order = append(order, moveIDs...)
if _, e := tx.ExecContext(r.Context(), `UPDATE "Bookmark" AS b SET "kanbanPosition"=t.ord-1 FROM unnest($1::text[]) WITH ORDINALITY AS t(bid,ord) WHERE b.id=t.bid AND b."ownerId"=$2`, order, user(r)); e != nil {
return e
}
moved = len(moveIDs)
}
if _, e := tx.ExecContext(r.Context(), `DELETE FROM "KanbanColumn" WHERE id=$1 AND "userId"=$2`, id, user(r)); e != nil {
return e
}
order := make([]string, len(remaining))
for i, c := range remaining {
order[i] = str(c, "id")
}
if _, e := tx.ExecContext(r.Context(), `UPDATE "KanbanColumn" AS k SET position=t.ord-1 FROM unnest($1::text[]) WITH ORDINALITY AS t(cid,ord) WHERE k.id=t.cid`, order); e != nil {
return e
}
return nil
})
respond(w, 200, object{"ok": true, "movedTo": dest, "moved": moved}, e)
}