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") // Only the three columns ResolveColumnID needs, and one set-based // UPDATE for the whole move instead of a round trip per bookmark. 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`, user(r)) if e != nil { return e } moveIDs := make([]string, 0, len(bms)) for _, b := range bms { if kanban.ResolveColumnID(b, cols) == id { moveIDs = append(moveIDs, str(b, "id")) } } 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,version=version+1,"updatedAt"=now() WHERE id=ANY($2::text[])`, dest, moveIDs); 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) }