package handlers import ( "context" "database/sql" "github.com/andrew/go-computer/internal/db" "github.com/andrew/go-computer/internal/markdown" "net/http" "strings" "time" ) // pageView is the canonical page projection over any queryer. HandlePagesUpdate // reads it INSIDE the write transaction (review CR-18): a post-commit query // could observe another writer's committed revision, and the acknowledgement // would then describe state this write did not produce — a client adopting it // could overwrite the other writer on its next save. func pageView(ctx context.Context, q db.Queryer, id, uid string) (object, error) { // Linked bookmarks use the viewer projection (review PRIV-02): a page can // link bookmarks shared with its owner, and those arrive narrowed. // // Connected pages (phase 3): backlinks and embeds are owner-scoped in the // query itself, not trusted from the caller. `links` is the resolved // outgoing edge set (useful for navigation); the Markdown remains the // source of truth and is rebuilt by syncPageGraph on every content save. return db.One(ctx, q, `SELECT to_jsonb(p)||jsonb_build_object( 'bookmarks',COALESCE((SELECT jsonb_agg(to_jsonb(pb)||jsonb_build_object('bookmark',`+db.BookmarkViewerView+`) ORDER BY pb.position) FROM "PageBookmark" pb JOIN "Bookmark" b ON b.id=pb."bookmarkId" WHERE pb."pageId"=p.id AND `+db.BookmarkVisible+`),'[]'::jsonb), 'backlinks',COALESCE((SELECT jsonb_agg(jsonb_build_object('id',s.id,'title',s.title,'updatedAt',s."updatedAt") ORDER BY s."updatedAt" DESC,s.id) FROM "PageLink" pl JOIN "Page" s ON s.id=pl."sourcePageId" WHERE pl."targetPageId"=p.id AND s."ownerId"=p."ownerId"),'[]'::jsonb), 'links',COALESCE((SELECT jsonb_agg(jsonb_build_object('id',t.id,'title',t.title) ORDER BY t.title,t.id) FROM "PageLink" pl JOIN "Page" t ON t.id=pl."targetPageId" WHERE pl."sourcePageId"=p.id AND t."ownerId"=p."ownerId"),'[]'::jsonb), 'embeds',COALESCE((SELECT jsonb_agg(jsonb_build_object( 'collectionId',e."collectionId",'viewId',e."viewId",'position',e.position, 'collection',to_jsonb(c)||jsonb_build_object( 'properties',COALESCE((SELECT jsonb_agg(to_jsonb(pr) ORDER BY pr.position,pr.id) FROM "CollectionProperty" pr WHERE pr."collectionId"=c.id),'[]'::jsonb), 'views',COALESCE((SELECT jsonb_agg(to_jsonb(v) ORDER BY v.position,v.id) FROM "CollectionView" v WHERE v."collectionId"=c.id),'[]'::jsonb)), 'view',(SELECT to_jsonb(v) FROM "CollectionView" v WHERE v.id=e."viewId")) ORDER BY e.position,e."collectionId") FROM "PageEmbed" e JOIN "Collection" c ON c.id=e."collectionId" WHERE e."pageId"=p.id AND c."ownerId"=p."ownerId"),'[]'::jsonb) ) FROM "Page" p WHERE p.id=$1 AND p."ownerId"=$2`, id, uid) } func page(r *http.Request, id string) (object, error) { return pageView(r.Context(), database(r), id, user(r)) } func HandlePagesList(w http.ResponseWriter, r *http.Request) { // Review PERF-01: the list is a metadata projection — content bodies stay // out of it (the sidebar/tab strip only render titles) and are fetched on // demand via GET /api/pages/{id}. Search still matches content server-side. // Escape LIKE wildcards (review PERF-2): "%" and "_" in a search are // literal characters, not patterns. The pg_trgm indexes on title/content // let Postgres use the same ILIKE predicate without scanning every page. search := "%" + likeEscaper.Replace(r.URL.Query().Get("search")) + "%" v, e := db.Many(r.Context(), database(r), `SELECT (to_jsonb(p)-'content')||jsonb_build_object('_count',jsonb_build_object( 'bookmarks',(SELECT count(*) FROM "PageBookmark" pb WHERE pb."pageId"=p.id), 'children',(SELECT count(*) FROM "Page" ch WHERE ch."parentId"=p.id AND ch."ownerId"=p."ownerId"))) FROM "Page" p WHERE "ownerId"=$1 AND (title ILIKE $2 ESCAPE '\' OR content ILIKE $2 ESCAPE '\') ORDER BY pinned DESC,position,"createdAt"`, user(r), search) respond(w, 200, v, e) } func pageData(b object) (object, error) { d := fields(b, "title content icon pinned parentId") // parentId is nullable: an explicit null promotes the page to the root. if e := stringFields(d, "icon parentId", "title content"); e != nil { return nil, e } if e := boolFields(d, "pinned"); e != nil { return nil, e } if e := limitText(d, "content", maxPageContentLen); e != nil { return nil, e } if e := limitText(d, "icon", maxPageIconLen); e != nil { return nil, e } if _, ok := d["title"]; ok { s := strings.TrimSpace(str(d, "title")) if s == "" { s = "Untitled page" } rs := []rune(s) if len(rs) > 120 { rs = rs[:120] } d["title"] = string(rs) } return d, nil } func HandlePagesCreate(w http.ResponseWriter, r *http.Request) { b, ok := read(w, r) if !ok { return } d, e := pageData(b) if e != nil { respond(w, 0, nil, e) return } if _, ok := d["title"]; !ok { d["title"] = "Untitled page" } d["id"], d["ownerId"] = 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 } if raw, ok := d["parentId"]; ok { parent, e := pageParent(r.Context(), tx, d["id"].(string), user(r), raw) if e != nil { return e } d["parentId"] = parent } var n int if e := tx.QueryRowContext(r.Context(), `SELECT COALESCE(max(position),-1)+1 FROM "Page" WHERE "ownerId"=$1`, user(r)).Scan(&n); e != nil { return e } d["position"] = n var e error v, e = db.Insert(r.Context(), tx, "Page", d) if e != nil { return e } if content, ok := d["content"].(string); ok { if e := syncPageGraph(r.Context(), tx, d["id"].(string), user(r), content); e != nil { return e } } // Acknowledge the same snapshot the write produced (the update path's // CR-18 discipline), so the 201 already carries links/embeds. v, e = pageView(r.Context(), tx, d["id"].(string), user(r)) return e }) respond(w, 201, v, e) } func HandlePagesReorder(w http.ResponseWriter, r *http.Request) { b, ok := read(w, r) if !ok { return } list, e := ids(b, "order") if e == nil { e = reorder(r, "Page", "ownerId", list) } respond(w, 200, OK{true}, e) } func HandlePagesGet(w http.ResponseWriter, r *http.Request) { v, e := page(r, r.PathValue("id")) respond(w, 200, v, e) } func HandlePagesUpdate(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") b, ok := read(w, r) if !ok { return } d, e := pageData(b) if e != nil { respond(w, 0, nil, e) return } // Optimistic concurrency (review DATA-01): a client that sends the // version it edited from gets a 409 instead of silently overwriting a // newer save from another tab or device. The revision counter tracks // content edits — metadata patches (title/pin/links) neither require nor // bump it, so they cannot invalidate an editor's in-flight draft. baseVersion, hasVersion := 0, false if v, ok := b["version"]; ok { 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 } // Review DATA-05: concurrency must not be optional at the API boundary. // A content write without a base version is rejected outright — otherwise // any script or stale client could silently clobber newer content by // omitting the field. Explicit overwrites re-fetch the latest version and // send it (web `resolveOverwrite`); they never bypass this check. if _, hasContent := d["content"]; hasContent && !hasVersion { respond(w, 0, nil, bad("version is required when saving content; refetch the page and retry")) return } d["updatedAt"] = time.Now().UTC() // Review CR-18: the response snapshot is captured inside the same // transaction as the write, so the acknowledged revision always // describes exactly the state this request produced — never a later // commit by a competing writer. var v object e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error { // Same locking discipline as page creation/reorder: serializes // max(position)+1 so concurrent link adds cannot duplicate positions. if e := lockUser(r, tx); e != nil { return e } current, e := owned(r, tx, "Page", id, "ownerId") if e != nil { return e } if hasVersion { stored, _ := current["version"].(float64) if int(stored) != baseVersion { return conflict("Page was modified elsewhere; reload to see the latest version") } } if raw, ok := d["parentId"]; ok { parent, e := pageParent(r.Context(), tx, id, user(r), raw) if e != nil { return e } d["parentId"] = parent } if _, e := db.Update(r.Context(), tx, "Page", id, "ownerId", user(r), d); e != nil { return e } if content, ok := d["content"].(string); ok { if _, e := tx.ExecContext(r.Context(), `UPDATE "Page" SET version=version+1 WHERE id=$1`, id); e != nil { return e } // Rebuild resolved links/embeds in the same transaction as the // Markdown they were derived from. if e := syncPageGraph(r.Context(), tx, id, user(r), content); e != nil { return e } } if bid := str(b, "addBookmarkId"); bid != "" { if _, e := db.One(r.Context(), tx, `SELECT to_jsonb(b) FROM "Bookmark" b WHERE b.id=$1 AND `+db.BookmarkVisible, bid, user(r)); e != nil { return bad("Bookmark is not accessible") } _, e := tx.ExecContext(r.Context(), `INSERT INTO "PageBookmark"("pageId","bookmarkId",position) SELECT $1,$2,COALESCE(max(position),-1)+1 FROM "PageBookmark" WHERE "pageId"=$1 ON CONFLICT DO NOTHING`, id, bid) if e != nil { return e } } if bid := str(b, "removeBookmarkId"); bid != "" { _, e := tx.ExecContext(r.Context(), `DELETE FROM "PageBookmark" WHERE "pageId"=$1 AND "bookmarkId"=$2`, id, bid) if e != nil { return e } } v, e = pageView(r.Context(), tx, id, user(r)) return e }) respond(w, 200, v, e) } // HandlePagesBacklinks returns the pages that link to this one. It is a // separate read surface for navigation; the same owner-scoped list is // embedded in pageView for the editor. func HandlePagesBacklinks(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if _, e := owned(r, database(r), "Page", id, "ownerId"); e != nil { respond(w, 0, nil, e) return } v, e := db.Many(r.Context(), database(r), `SELECT jsonb_build_object('id',s.id,'title',s.title,'updatedAt',s."updatedAt") FROM "PageLink" pl JOIN "Page" s ON s.id=pl."sourcePageId" WHERE pl."targetPageId"=$1 AND s."ownerId"=$2 ORDER BY s."updatedAt" DESC,s.id`, id, user(r)) respond(w, 200, v, e) } func HandlePagesDelete(w http.ResponseWriter, r *http.Request) { // Children are promoted to the top level by the self-FK's SET NULL; a // delete never silently takes nested pages with it. respond(w, 200, OK{true}, remove(r, "Page", r.PathValue("id"), "ownerId")) } func HandlePagesMarkdown(w http.ResponseWriter, r *http.Request) { v, e := page(r, r.PathValue("id")) if e != nil { respond(w, 0, nil, e) return } markdown.Send(w, markdown.PageDocument(v), str(v, "title")) }