Files
pad/internal/server/handlers_items.go
T
xarmian e5eae5e94e feat(web): persistent dismissible CLI-connect banner on workspace pages (TASK-862) (#284)
* feat(web): persistent dismissible CLI-connect banner on workspace pages (TASK-862)

Final web piece of the web-first onboarding on-ramp from PLAN-859 / IDEA-750.
A slim banner now nudges users to connect their workspace to the CLI on
every workspace page, until they either dismiss it or actually do it.

Server:

- New store method WorkspaceHasCLISource(workspaceID) — backed by
  EXISTS(... WHERE source='cli' AND deleted_at IS NULL), so it's a
  cheap O(1) check that short-circuits on the first match.
- Dashboard payload (GET /workspaces/{ws}/dashboard) gains
  HasCLISource bool (json: has_cli_source).
- Unit tests cover empty workspace, web/skill items don't trip it,
  one cli item flips it on, soft-delete flips it back off, and
  cross-workspace isolation.

Web:

- New <ConnectBanner> Svelte 5 component
  (web/src/lib/components/ConnectBanner.svelte). Self-contained:
  reads dismissed state from localStorage, fetches has_cli_source
  itself, mounts <ConnectWorkspaceModal> internally. Two split
  $effect blocks per CONVE-606 — one for the localStorage sync, one
  for the dashboard fetch — so a workspace change doesn't entangle
  the two reactive lifecycles.
- Banner is hidden while loading (hasCliSource === null) to avoid a
  flash-then-auto-hide on workspaces that already have CLI items.
- Storage key pad-cli-banner-dismissed-${wsSlug} matches the existing
  onboarding-dismissed pattern. Per-browser only; TODO comment in
  source about backing it with a workspace_user_state row if cross-
  device persistence is wanted later.
- Mounted in web/src/routes/[username]/[workspace]/+layout.svelte
  above {@render children()} so it appears on every workspace page
  (dashboard, collection lists, item detail, search, activity, etc.)
  and NOT on console/auth pages (the layout is workspace-scoped).
- DashboardResponse type in web/src/lib/types/index.ts gains
  has_cli_source: boolean.

Smoke-tested against the running server: the field is live in the
dashboard payload and reflects reality (this workspace returns
has_cli_source: true since it has many CLI-sourced items, so the
banner is correctly auto-hidden here).

Test plan:
- go build ./... && go test ./... — all green (incl. new
  TestWorkspaceHasCLISource with 5 sub-cases).
- cd web && npm run build — clean.
- make install — clean, server restarted.
- Svelte MCP autofixer ran on ConnectBanner.svelte — no issues.

Parent: PLAN-859. Driving idea: IDEA-750.

* fix(web/connect-banner): stale-response guard + refetch on modal close (Codex round 1)

Two findings from Codex review on PR #284:

1. Stale-response race: rapid workspace switches could let a slow
   dashboard fetch from workspace A overwrite hasCliSource for
   workspace B after the user navigated. Capture the requested slug
   at fetch time, ignore the response if wsSlug has changed since.

2. Auto-hide didn't work in-session: if a user opened the banner
   modal, copied the command, ran it elsewhere, and closed the modal,
   the banner stayed visible because hasCliSource was stale. Refetch
   when the modal transitions from open → closed (the natural moment
   the user has just connected). Uses $effect.pre with a tracked
   previous value, matching the transition pattern in ShareDialog.

The 'someone ran the CLI from another terminal without ever opening
the modal' edge case is left for a follow-up — would require SSE
item-created subscription, which is heavier than this PR's scope.

* fix(server/items): persist source from auth context on create (Codex round 2)

Codex caught an architectural bug while reviewing the TASK-862 banner
work: items created via the CLI were persisting with source='web'
(the column default) instead of 'cli', because handleCreateItem decoded
ItemCreate from the body — which the CLI doesn't set Source on — and
only consulted actorFromRequest AFTER persisting (for SSE / activity
log emission). Result: TASK-862's has_cli_source dashboard signal
would never flip on for normal CLI usage, so the connect-CLI banner
would never auto-hide for users who actually wired up the CLI.

Fix: in handleCreateItem, backfill input.Source from actorFromRequest
before calling store.CreateItem, but only when the client didn't
explicitly set it (so agents marking themselves as 'skill' still
pass through unchanged).

Test: TestCreateItemSourcePersistedFromAuth covers all three branches
- bearer Authorization header → source=cli (uses bootstrap + a real
  session token in the header since the auth middleware validates
  token format and rejects fake values with 401 before the handler
  runs)
- cookie session, no Authorization → source=web
- explicit source in body wins over auth-derived (e.g. 'skill')

* fix(web/connect-banner): seq counter for same-workspace race (Codex round 3)

Round 3 caught a same-workspace race the slug guard didn't cover: an
in-flight workspace-change fetch that resolves AFTER the modal-close
refetch could overwrite the newer 'true' with the older 'false',
making the banner reappear after the user actually wired up the CLI.

Add a monotonic fetchSeq counter — captured at call time, rechecked
before applying the response. Only the LATEST request's result wins,
regardless of arrival order. The slug guard stays as a second-layer
defense for cross-workspace races.

* fix(web/connect-banner): guard banner keydown to currentTarget (Codex round 4)

Round 4 caught a keyboard-event bubble: pressing Enter or Space on
the dismiss X button also fired the banner-level keydown handler,
so the user would dismiss AND open the modal in one stroke.

Guard the parent handler with `e.target !== e.currentTarget` so it
only reacts to keydown that originated on the banner itself. Tabbing
to the dismiss button + Enter now ONLY dismisses.

* fix(store): visibility-filter has_cli_source query (Codex round 5)

Round 5 caught a P2 information leak: WorkspaceHasCLISource scanned
the entire workspace regardless of caller visibility, so a guest
with grants only on web-sourced items could still see has_cli_source
return true (revealing that CLI items exist somewhere they can't see).
That also produced wrong UX — the banner could auto-hide for guests
who couldn't actually use the CLI.

Extend the query to take optional collectionIDs/itemIDs filters
matching the dashboard's existing visibility model: an item counts
when its collection is in collectionIDs OR its id is in itemIDs
(union — guest item-level grants can expose items in otherwise-
hidden collections). Mirrors ListItems' filtering pattern incl. the
"non-nil empty CollectionIDs = no visibility = short-circuit false"
semantics.

Handler now passes dashCollIDs and dashItemIDs to match the rest of
the dashboard payload's filtering. New TestWorkspaceHasCLISourceVisibility
covers the four cases: unfiltered sees all, visible-coll-only hides
CLI items in hidden collections, item-level grant surfaces a hidden
CLI item, and empty visibility short-circuits to false.
2026-04-29 10:05:34 -04:00

1284 lines
38 KiB
Go

package server
import (
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/PerpetualSoftware/pad/internal/events"
"github.com/PerpetualSoftware/pad/internal/items"
"github.com/PerpetualSoftware/pad/internal/models"
)
// handleListItems lists all items across collections in a workspace.
func (s *Server) handleListItems(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
params := parseItemListParams(r)
if err := s.resolveParentFilter(r, workspaceID, &params); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
// Apply collection visibility filter
visibleIDs, err := s.visibleCollectionIDs(r, workspaceID)
if err != nil {
writeInternalError(w, err)
return
}
params.CollectionIDs = visibleIDs
// Apply item-level filtering for users with item grants (guests or
// restricted members) so item grants don't leak entire collections.
fullCollIDs, grantedItemIDs, grantErr := s.guestResourceFilter(r, workspaceID)
if grantErr != nil {
writeInternalError(w, grantErr)
return
}
if len(grantedItemIDs) > 0 {
params.CollectionIDs = fullCollIDs
params.ItemIDs = grantedItemIDs
}
result, err := s.store.ListItems(workspaceID, params)
if err != nil {
writeInternalError(w, err)
return
}
if result == nil {
result = []models.Item{}
}
s.enrichItemsWithParent(workspaceID, result, visibleIDs)
writeJSON(w, http.StatusOK, result)
}
// handleListCollectionItems lists items within a specific collection.
func (s *Server) handleListCollectionItems(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
collSlug := chi.URLParam(r, "collSlug")
coll, err := s.store.GetCollectionBySlug(workspaceID, collSlug)
if err != nil {
writeInternalError(w, err)
return
}
if coll == nil {
writeError(w, http.StatusNotFound, "not_found", "Collection not found")
return
}
// Gate check: is this collection visible to the user?
visibleIDs, err := s.visibleCollectionIDs(r, workspaceID)
if err != nil {
writeInternalError(w, err)
return
}
if !isCollectionVisible(coll.ID, visibleIDs) {
writeError(w, http.StatusNotFound, "not_found", "Collection not found")
return
}
params := parseItemListParams(r)
params.CollectionSlug = collSlug
// For users with item-level grants, if this collection's visibility comes
// from item-level grants (not a full collection grant), restrict to only
// the granted items. Applies to both guests and restricted members.
lcFullCollIDs, lcGrantedItemIDs, lcGrantErr := s.guestResourceFilter(r, workspaceID)
if lcGrantErr != nil {
writeInternalError(w, lcGrantErr)
return
}
if len(lcGrantedItemIDs) > 0 {
hasFullCollectionGrant := false
for _, id := range lcFullCollIDs {
if id == coll.ID {
hasFullCollectionGrant = true
break
}
}
// Also check member_collection_access for restricted members
if !hasFullCollectionGrant && workspaceRole(r) != "guest" {
memberColls, _ := s.store.GetMemberCollectionAccess(workspaceID, currentUserID(r))
for _, id := range memberColls {
if id == coll.ID {
hasFullCollectionGrant = true
break
}
}
}
if !hasFullCollectionGrant {
params.ItemIDs = lcGrantedItemIDs
}
}
var collSchema models.CollectionSchema
if coll.Schema != "" {
_ = json.Unmarshal([]byte(coll.Schema), &collSchema)
}
if err := s.resolveParentFilter(r, workspaceID, &params, collSchema); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
result, err := s.store.ListItems(workspaceID, params)
if err != nil {
writeInternalError(w, err)
return
}
if result == nil {
result = []models.Item{}
}
s.enrichItemsWithParent(workspaceID, result, visibleIDs)
writeJSON(w, http.StatusOK, result)
}
// handleCreateItem creates a new item in a collection, validating fields against the schema.
func (s *Server) handleCreateItem(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
collSlug := chi.URLParam(r, "collSlug")
coll, err := s.store.GetCollectionBySlug(workspaceID, collSlug)
if err != nil {
writeInternalError(w, err)
return
}
if coll == nil {
writeError(w, http.StatusNotFound, "not_found", "Collection not found")
return
}
// Check edit permission (grant-aware for guests)
if !s.requireEditPermission(w, r, workspaceID, "", coll.ID) {
return
}
// Check collection visibility
visibleIDs, visErr := s.visibleCollectionIDs(r, workspaceID)
if visErr != nil {
writeInternalError(w, visErr)
return
}
if !isCollectionVisible(coll.ID, visibleIDs) {
writeError(w, http.StatusNotFound, "not_found", "Collection not found")
return
}
var input models.ItemCreate
if err := decodeJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
if input.Title == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Title is required")
return
}
// Enforce item count limit (workspace-scoped)
if !s.enforcePlanLimit(w, workspaceID, "items_per_workspace") {
return
}
// Parse collection schema
var schema models.CollectionSchema
if err := json.Unmarshal([]byte(coll.Schema), &schema); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to parse collection schema")
return
}
// Parse and validate input fields
fieldMap := make(map[string]any)
if input.Fields != "" {
if err := json.Unmarshal([]byte(input.Fields), &fieldMap); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid fields JSON")
return
}
}
// Extract parent from fields — it's managed via item_links, not stored in fields JSON.
// Accepts both "parent" and "plan" as the field key.
// Skip this if the schema actually defines a field with that key.
var parentValue string
for _, key := range []string{"parent", "plan"} {
if schemaHasField(schema, key) {
continue
}
if pv, ok := fieldMap[key]; ok && pv != nil {
if pvStr, ok := pv.(string); ok && pvStr != "" {
var resolvedParent *models.Item
if !isUUID(pvStr) {
resolvedParent, err = s.store.ResolveItem(workspaceID, pvStr)
if err != nil || resolvedParent == nil {
writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("parent %q not found", pvStr))
return
}
parentValue = resolvedParent.ID
} else {
resolvedParent, _ = s.store.GetItem(pvStr)
if resolvedParent == nil {
writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("parent %q not found", pvStr))
return
}
parentValue = resolvedParent.ID
}
// Ensure parent item belongs to this workspace and is visible
if resolvedParent.WorkspaceID != workspaceID {
writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("parent %q not found", pvStr))
return
}
if !s.requireItemVisible(w, r, workspaceID, resolvedParent) {
return
}
}
delete(fieldMap, key)
}
}
if err := items.ValidateFields(fieldMap, schema); err != nil {
writeError(w, http.StatusBadRequest, "validation_error", err.Error())
return
}
// Marshal validated/defaulted fields back
validatedFields, err := json.Marshal(fieldMap)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to marshal validated fields")
return
}
input.Fields = string(validatedFields)
// Persist the source from the request's auth context so the item row
// reflects which client created it. Without this, items created via
// the CLI would persist as 'web' (the column default) since the CLI's
// ItemCreate body has no Source field set, and downstream signals like
// the dashboard's has_cli_source flag (TASK-862) would never flip on.
// If a client explicitly sent a source in the body (e.g. an agent
// marking itself as 'skill'), respect it.
if input.Source == "" {
_, src := actorFromRequest(r)
input.Source = src
}
item, err := s.store.CreateItem(workspaceID, coll.ID, input)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint") {
writeError(w, http.StatusConflict, "conflict", "An item with this title already exists")
return
}
writeInternalError(w, err)
return
}
// Create parent link if specified
if parentValue != "" {
actor, _ := actorFromRequest(r)
if _, err := s.store.SetParentLink(workspaceID, item.ID, parentValue, actor); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", fmt.Sprintf("item created but parent link failed: %v", err))
return
}
}
actor, source := actorFromRequest(r)
s.logActivity(workspaceID, item.ID, "created", r)
s.publishItemEventWithName(events.ItemCreated, workspaceID, item.ID, item.Title, collSlug, actor, actorNameFromRequest(r), source)
s.dispatchWebhook(workspaceID, "item.created", item)
createVisIDs, _ := s.visibleCollectionIDs(r, workspaceID)
if err := s.enrichItemForResponse(item, createVisIDs); err != nil {
writeInternalError(w, err)
return
}
writeJSON(w, http.StatusCreated, item)
}
// handleGetItem retrieves a single item by slug.
func (s *Server) handleGetItem(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
itemSlug := chi.URLParam(r, "itemSlug")
item, err := s.store.ResolveItem(workspaceID, itemSlug)
if err != nil {
writeInternalError(w, err)
return
}
if item == nil {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
if !s.requireItemVisible(w, r, workspaceID, item) {
return
}
enrichVisIDs, _ := s.visibleCollectionIDs(r, workspaceID)
if err := s.enrichItemForResponse(item, enrichVisIDs); err != nil {
writeInternalError(w, err)
return
}
writeJSON(w, http.StatusOK, item)
}
// handleUpdateItem updates an existing item (fields, content, or both).
func (s *Server) handleUpdateItem(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
itemSlug := chi.URLParam(r, "itemSlug")
item, err := s.store.ResolveItem(workspaceID, itemSlug)
if err != nil {
writeInternalError(w, err)
return
}
if item == nil {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
if !s.requireItemVisible(w, r, workspaceID, item) {
return
}
// Check edit permission (grant-aware for guests)
if !s.requireEditPermission(w, r, workspaceID, item.ID, item.CollectionID) {
return
}
var input models.ItemUpdate
if err := decodeJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
// If fields are being updated, validate against schema
if input.Fields != nil {
coll, err := s.store.GetCollection(item.CollectionID)
if err != nil || coll == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load collection")
return
}
var schema models.CollectionSchema
if err := json.Unmarshal([]byte(coll.Schema), &schema); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to parse collection schema")
return
}
fieldMap := make(map[string]any)
if err := json.Unmarshal([]byte(*input.Fields), &fieldMap); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid fields JSON")
return
}
// Extract parent from fields — it's managed via item_links, not stored in fields JSON.
// Accepts both "parent" and "plan" as the field key.
// Skip this if the schema actually defines a field with that key.
var parentValue string
var parentProvided bool
for _, key := range []string{"parent", "plan"} {
if schemaHasField(schema, key) {
continue
}
if pv, ok := fieldMap[key]; ok {
parentProvided = true
if pv != nil {
if pvStr, ok := pv.(string); ok && pvStr != "" {
var resolvedParent *models.Item
if !isUUID(pvStr) {
resolvedParent, err = s.store.ResolveItem(workspaceID, pvStr)
if err != nil || resolvedParent == nil {
writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("parent %q not found", pvStr))
return
}
parentValue = resolvedParent.ID
} else {
resolvedParent, _ = s.store.GetItem(pvStr)
if resolvedParent == nil {
writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("parent %q not found", pvStr))
return
}
parentValue = resolvedParent.ID
}
// Ensure parent item belongs to this workspace and is visible
if resolvedParent.WorkspaceID != workspaceID {
writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("parent %q not found", pvStr))
return
}
if !s.requireItemVisible(w, r, workspaceID, resolvedParent) {
return
}
}
}
delete(fieldMap, key)
}
}
if err := items.ValidateFields(fieldMap, schema); err != nil {
writeError(w, http.StatusBadRequest, "validation_error", err.Error())
return
}
// Auto-populate date fields on status changes
autoPopulateDates(fieldMap, item.Fields, schema)
validatedFields, err := json.Marshal(fieldMap)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to marshal validated fields")
return
}
validated := string(validatedFields)
input.Fields = &validated
// Update parent link if parent was provided in the update
if parentProvided {
if parentValue != "" {
actor, _ := actorFromRequest(r)
if _, err := s.store.SetParentLink(workspaceID, item.ID, parentValue, actor); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", fmt.Sprintf("failed to update parent link: %v", err))
return
}
} else {
// Parent was explicitly set to empty/null — clear the link
if err := s.store.ClearParentLink(item.ID); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", fmt.Sprintf("failed to clear parent link: %v", err))
return
}
}
}
}
updated, err := s.store.UpdateItem(item.ID, input)
if err != nil {
writeInternalError(w, err)
return
}
if updated == nil {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
// Build rich metadata describing what changed
var meta string
if changes := diffFields(item.Fields, updated.Fields); changes != "" {
meta = fmt.Sprintf(`{"changes":%q}`, changes)
}
if input.Title != nil && *input.Title != item.Title {
if meta == "" {
titleChange := fmt.Sprintf("title: %s → %s", item.Title, *input.Title)
meta = fmt.Sprintf(`{"changes":%q}`, titleChange)
}
}
// Track role and assignment changes
if updated.AgentRoleSlug != item.AgentRoleSlug {
roleChange := fmt.Sprintf("role: %s → %s", valueOrEmpty(item.AgentRoleName), valueOrEmpty(updated.AgentRoleName))
meta = appendChange(meta, roleChange)
}
if updated.AssignedUserName != item.AssignedUserName {
assignChange := fmt.Sprintf("assigned: %s → %s", valueOrEmpty(item.AssignedUserName), valueOrEmpty(updated.AssignedUserName))
meta = appendChange(meta, assignChange)
}
actor, source := actorFromRequest(r)
activityID, _ := s.logActivityWithMetaReturningID(workspaceID, updated.ID, "updated", r, meta)
s.publishItemEventWithName(events.ItemUpdated, workspaceID, updated.ID, updated.Title, updated.CollectionSlug, actor, actorNameFromRequest(r), source)
s.dispatchWebhook(workspaceID, "item.updated", updated)
// If a comment was attached to this update (e.g. explaining a status change),
// create a comment linked to the activity entry.
if input.Comment != nil && strings.TrimSpace(*input.Comment) != "" {
commentInput := models.CommentCreate{
Body: strings.TrimSpace(*input.Comment),
ActivityID: activityID,
}
if u := currentUser(r); u != nil {
commentInput.Author = u.Name
}
commentInput.CreatedBy = actor
commentInput.Source = source
comment, cerr := s.store.CreateComment(workspaceID, updated.ID, commentInput)
if cerr != nil {
slog.Warn("failed to create comment on item update", "item_id", updated.ID, "error", cerr)
}
if cerr == nil && comment != nil {
s.publishCommentEvent(events.CommentCreated, workspaceID, updated.ID, comment.ID, updated.Title, updated.CollectionSlug, actor, source)
s.dispatchWebhook(workspaceID, "item.updated_with_comment", map[string]interface{}{
"item": updated,
"comment": comment,
"changes": meta,
})
}
}
updateVisIDs, _ := s.visibleCollectionIDs(r, workspaceID)
if err := s.enrichItemForResponse(updated, updateVisIDs); err != nil {
writeInternalError(w, err)
return
}
writeJSON(w, http.StatusOK, updated)
}
// handleDeleteItem archives (soft-deletes) an item.
func (s *Server) handleDeleteItem(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
itemSlug := chi.URLParam(r, "itemSlug")
item, err := s.store.ResolveItem(workspaceID, itemSlug)
if err != nil {
writeInternalError(w, err)
return
}
if item == nil {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
if !s.requireItemVisible(w, r, workspaceID, item) {
return
}
// Check edit permission (grant-aware for guests)
if !s.requireEditPermission(w, r, workspaceID, item.ID, item.CollectionID) {
return
}
if err := s.store.DeleteItem(item.ID); err != nil {
writeInternalError(w, err)
return
}
actor, source := actorFromRequest(r)
s.logActivity(workspaceID, item.ID, "archived", r)
s.publishItemEventWithName(events.ItemArchived, workspaceID, item.ID, item.Title, item.CollectionSlug, actor, actorNameFromRequest(r), source)
s.dispatchWebhook(workspaceID, "item.deleted", item)
w.WriteHeader(http.StatusNoContent)
}
// handleRestoreItem restores an archived item.
func (s *Server) handleRestoreItem(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
itemSlug := chi.URLParam(r, "itemSlug")
// We need to find the item even if deleted (for restore).
item, err := s.store.ResolveItemIncludeDeleted(workspaceID, itemSlug)
if err != nil {
writeInternalError(w, err)
return
}
if item == nil {
writeError(w, http.StatusNotFound, "not_found", "Item not found or not archived")
return
}
if !s.requireItemVisible(w, r, workspaceID, item) {
return
}
// Check edit permission (grant-aware for guests)
if !s.requireEditPermission(w, r, workspaceID, item.ID, item.CollectionID) {
return
}
restored, err := s.store.RestoreItem(item.ID)
if err != nil {
if err == sql.ErrNoRows {
writeError(w, http.StatusNotFound, "not_found", "Item not found or not archived")
return
}
writeInternalError(w, err)
return
}
actor, source := actorFromRequest(r)
s.logActivity(workspaceID, restored.ID, "restored", r)
s.publishItemEventWithName(events.ItemRestored, workspaceID, restored.ID, restored.Title, restored.CollectionSlug, actor, actorNameFromRequest(r), source)
restoreVisIDs, _ := s.visibleCollectionIDs(r, workspaceID)
if err := s.enrichItemForResponse(restored, restoreVisIDs); err != nil {
writeInternalError(w, err)
return
}
writeJSON(w, http.StatusOK, restored)
}
// handleMoveItem moves an item to a different collection with field migration.
func (s *Server) handleMoveItem(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
itemSlug := chi.URLParam(r, "itemSlug")
item, err := s.store.ResolveItem(workspaceID, itemSlug)
if err != nil || item == nil {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
if !s.requireItemVisible(w, r, workspaceID, item) {
return
}
// Check edit permission (grant-aware for guests)
if !s.requireEditPermission(w, r, workspaceID, item.ID, item.CollectionID) {
return
}
var input struct {
TargetCollection string `json:"target_collection"`
FieldOverrides map[string]any `json:"field_overrides"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid_body", "Invalid JSON body")
return
}
if input.TargetCollection == "" {
writeError(w, http.StatusBadRequest, "missing_field", "target_collection is required")
return
}
// Get target collection, verify it's visible, and check edit permission
targetColl, err := s.store.GetCollectionBySlug(workspaceID, input.TargetCollection)
if err != nil || targetColl == nil {
writeError(w, http.StatusBadRequest, "invalid_collection", "Target collection not found")
return
}
targetVisibleIDs, visErr := s.visibleCollectionIDs(r, workspaceID)
if visErr != nil {
writeInternalError(w, visErr)
return
}
if !isCollectionVisible(targetColl.ID, targetVisibleIDs) {
writeError(w, http.StatusBadRequest, "invalid_collection", "Target collection not found")
return
}
// Require edit permission on the target collection (not just visibility)
if !s.requireEditPermission(w, r, workspaceID, "", targetColl.ID) {
return
}
// Don't move to the same collection
if targetColl.ID == item.CollectionID {
writeError(w, http.StatusBadRequest, "same_collection", "Item is already in this collection")
return
}
// Get source collection for schema
sourceColl, err := s.store.GetCollection(item.CollectionID)
if err != nil || sourceColl == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get source collection")
return
}
// Parse schemas
var sourceSchema, targetSchema models.CollectionSchema
if err := json.Unmarshal([]byte(sourceColl.Schema), &sourceSchema); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to parse source schema")
return
}
if err := json.Unmarshal([]byte(targetColl.Schema), &targetSchema); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to parse target schema")
return
}
// Parse current fields
var currentFields map[string]any
if err := json.Unmarshal([]byte(item.Fields), &currentFields); err != nil {
currentFields = make(map[string]any)
}
// Migrate fields
result := items.MigrateFields(currentFields, sourceSchema.Fields, targetSchema.Fields)
// Apply overrides
for k, v := range input.FieldOverrides {
result.Fields[k] = v
}
// Check for required field errors (after overrides)
if len(result.Errors) > 0 {
writeError(w, http.StatusBadRequest, "missing_required_fields",
fmt.Sprintf("Required fields missing: %s", strings.Join(result.Errors, ", ")))
return
}
// Serialize migrated fields
fieldsJSON, err := json.Marshal(result.Fields)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to serialize fields")
return
}
// Move the item
moved, err := s.store.MoveItem(item.ID, targetColl.ID, string(fieldsJSON))
if err != nil {
writeInternalError(w, err)
return
}
// Log activity with metadata about the move
actor, source := actorFromRequest(r)
moveMeta := auditMeta(map[string]string{"from_collection": sourceColl.Slug, "to_collection": targetColl.Slug})
s.logActivityWithMeta(workspaceID, moved.ID, "moved", r, moveMeta)
// Publish events for both old and new collections
s.publishItemEventWithName(events.ItemUpdated, workspaceID, moved.ID, moved.Title, targetColl.Slug, actor, actorNameFromRequest(r), source)
s.dispatchWebhook(workspaceID, "item.moved", moved)
moveVisIDs, _ := s.visibleCollectionIDs(r, workspaceID)
if err := s.enrichItemForResponse(moved, moveVisIDs); err != nil {
writeInternalError(w, err)
return
}
writeJSON(w, http.StatusOK, moved)
}
// publishItemEventWithName publishes a real-time event for item changes with actor name.
func (s *Server) publishItemEventWithName(eventType, workspaceID, itemID, title, collection, actor, actorName, source string) {
if s.events == nil {
return
}
s.events.Publish(events.Event{
Type: eventType,
WorkspaceID: workspaceID,
ItemID: itemID,
Collection: collection,
Title: title,
Actor: actor,
ActorName: actorName,
Source: source,
})
}
// handlePlansProgress returns child item completion progress for all non-deleted plans.
// This is a backward-compat endpoint; the general form is per-item via /items/{slug}/children.
func (s *Server) handlePlansProgress(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
// Check that the plans collection is visible to this user
visibleIDs, err := s.visibleCollectionIDs(r, workspaceID)
if err != nil {
writeInternalError(w, err)
return
}
ppFullCollIDs, ppGrantedItemIDs, ppGrantErr := s.guestResourceFilter(r, workspaceID)
if ppGrantErr != nil {
writeInternalError(w, ppGrantErr)
return
}
if visibleIDs != nil {
plansColl, _ := s.store.GetCollectionBySlug(workspaceID, "plans")
if plansColl == nil || !isCollectionVisible(plansColl.ID, visibleIDs) {
writeJSON(w, http.StatusOK, []interface{}{})
return
}
}
// When user has restricted visibility, compute progress from visible
// children only so hidden child counts don't leak.
if visibleIDs != nil {
allProgress, err := s.store.GetAllItemProgress(workspaceID, "plans")
if err != nil {
writeInternalError(w, err)
return
}
// For guests with item-level grants, filter plans themselves
if len(ppGrantedItemIDs) > 0 {
ppGrantedSet := make(map[string]bool, len(ppGrantedItemIDs))
for _, id := range ppGrantedItemIDs {
ppGrantedSet[id] = true
}
ppFullSet := make(map[string]bool, len(ppFullCollIDs))
for _, id := range ppFullCollIDs {
ppFullSet[id] = true
}
// Check if plans collection has a full grant
plansColl, _ := s.store.GetCollectionBySlug(workspaceID, "plans")
if plansColl != nil && !ppFullSet[plansColl.ID] {
filtered := allProgress[:0]
for _, p := range allProgress {
if ppGrantedSet[p.ItemID] {
filtered = append(filtered, p)
}
}
allProgress = filtered
}
}
// Build a done-context map once so each child is evaluated against
// its own collection's configured done field, not a global default.
// ListCollectionsMinimal avoids the per-collection COUNT queries
// that ListCollections would run — we only need schema + settings.
wsCollections, _ := s.store.ListCollectionsMinimal(workspaceID)
ctxMap := buildDoneContextMap(wsCollections)
// Recompute each plan's progress using only visible children
for i, p := range allProgress {
children, cerr := s.store.GetChildItems(p.ItemID)
if cerr != nil {
continue
}
total, done := 0, 0
for _, child := range children {
if !isCollectionVisible(child.CollectionID, visibleIDs) {
continue
}
if !s.isItemVisibleToGuest(r, workspaceID, &child, ppFullCollIDs, ppGrantedItemIDs) {
continue
}
total++
if isItemDone(child.Fields, child.CollectionID, ctxMap) {
done++
}
}
allProgress[i].Total = total
allProgress[i].Done = done
}
writeJSON(w, http.StatusOK, allProgress)
return
}
progress, err := s.store.GetAllItemProgress(workspaceID, "plans")
if err != nil {
writeInternalError(w, err)
return
}
writeJSON(w, http.StatusOK, progress)
}
// handleGetItemChildren returns all child items linked to a parent item.
// This is the generalized version — children can come from any collection.
func (s *Server) handleGetItemChildren(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
itemSlug := chi.URLParam(r, "itemSlug")
item, err := s.store.ResolveItem(workspaceID, itemSlug)
if err != nil {
writeInternalError(w, err)
return
}
if item == nil {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
if !s.requireItemVisible(w, r, workspaceID, item) {
return
}
children, err := s.store.GetChildItems(item.ID)
if err != nil {
writeInternalError(w, err)
return
}
if children == nil {
children = []models.Item{}
}
// Filter children by collection visibility and item-level grants
visibleIDs, visErr := s.visibleCollectionIDs(r, workspaceID)
if visErr != nil {
writeInternalError(w, visErr)
return
}
fullCollIDs, grantedItemIDs, grantErr := s.guestResourceFilter(r, workspaceID)
if grantErr != nil {
writeInternalError(w, grantErr)
return
}
if visibleIDs != nil {
filtered := children[:0]
for _, child := range children {
if !isCollectionVisible(child.CollectionID, visibleIDs) {
continue
}
if !s.isItemVisibleToGuest(r, workspaceID, &child, fullCollIDs, grantedItemIDs) {
continue
}
filtered = append(filtered, child)
}
children = filtered
}
s.enrichItemsWithParent(workspaceID, children, visibleIDs)
if visibleIDs != nil {
// Visibility-aware has_children: only count visible grandchildren
for i := range children {
grandchildren, _ := s.store.GetChildItems(children[i].ID)
children[i].HasChildren = false
for _, gc := range grandchildren {
if !isCollectionVisible(gc.CollectionID, visibleIDs) {
continue
}
if !s.isItemVisibleToGuest(r, workspaceID, &gc, fullCollIDs, grantedItemIDs) {
continue
}
children[i].HasChildren = true
break
}
}
} else {
s.store.PopulateHasChildren(children)
}
writeJSON(w, http.StatusOK, children)
}
// handleGetItemProgress returns completion progress for an item's children.
// Response: {"total": N, "done": N, "percentage": N}
func (s *Server) handleGetItemProgress(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
itemSlug := chi.URLParam(r, "itemSlug")
item, err := s.store.ResolveItem(workspaceID, itemSlug)
if err != nil {
writeInternalError(w, err)
return
}
if item == nil {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
if !s.requireItemVisible(w, r, workspaceID, item) {
return
}
// Get visibility filter; when restricted, compute progress from
// visible children only so hidden child counts don't leak.
progVisIDs, _ := s.visibleCollectionIDs(r, workspaceID)
progFullCollIDs, progGrantedItemIDs, progGrantErr := s.guestResourceFilter(r, workspaceID)
if progGrantErr != nil {
writeInternalError(w, progGrantErr)
return
}
if progVisIDs != nil {
// Restricted: compute from visible children in Go using per-collection
// schemas to determine terminal statuses correctly.
children, cerr := s.store.GetChildItems(item.ID)
if cerr != nil {
writeInternalError(w, cerr)
return
}
// Cache a schema+settings done-context per child collection so
// each child is evaluated against its own configured done field.
ctxCache := make(map[string]doneContext)
total, done := 0, 0
for _, child := range children {
if !isCollectionVisible(child.CollectionID, progVisIDs) {
continue
}
if !s.isItemVisibleToGuest(r, workspaceID, &child, progFullCollIDs, progGrantedItemIDs) {
continue
}
total++
ctx, cached := ctxCache[child.CollectionID]
if !cached {
if coll, cerr := s.store.GetCollection(child.CollectionID); cerr == nil && coll != nil {
_ = json.Unmarshal([]byte(coll.Schema), &ctx.schema)
if coll.Settings != "" {
_ = json.Unmarshal([]byte(coll.Settings), &ctx.settings)
}
}
ctxCache[child.CollectionID] = ctx
}
// Build a one-entry ctx map for isItemDone so it hits the
// typed-context branch rather than the status-only fallback.
if isItemDone(child.Fields, child.CollectionID, map[string]doneContext{child.CollectionID: ctx}) {
done++
}
}
pct := 0
if total > 0 {
pct = (done * 100) / total
}
writeJSON(w, http.StatusOK, map[string]int{
"total": total,
"done": done,
"percentage": pct,
})
return
}
total, done, err := s.store.GetItemProgress(item.ID)
if err != nil {
writeInternalError(w, err)
return
}
pct := 0
if total > 0 {
pct = (done * 100) / total
}
writeJSON(w, http.StatusOK, map[string]int{
"total": total,
"done": done,
"percentage": pct,
})
}
// resolveParentFilter extracts a "parent" (or "plan") key from the field
// filters and converts it to a ParentLinkID filter (which uses item_links instead of json_extract).
// An optional schema can be passed; if the schema defines a field with the key,
// that key is left as a normal field filter instead of being treated as a parent link.
func (s *Server) resolveParentFilter(r *http.Request, workspaceID string, params *models.ItemListParams, schemas ...models.CollectionSchema) error {
if params.Fields == nil {
return nil
}
// Accept both "parent" and "plan" as parent filter keys
// but skip if the schema defines a real field with that key
var schema *models.CollectionSchema
if len(schemas) > 0 {
schema = &schemas[0]
}
var val string
for _, key := range []string{"parent", "plan"} {
if schema != nil && schemaHasField(*schema, key) {
continue
}
if v, ok := params.Fields[key]; ok && v != "" {
val = v
delete(params.Fields, key)
break
}
}
if val == "" {
return nil
}
// Resolve slug/ref to UUID
var resolved *models.Item
if !isUUID(val) {
var rerr error
resolved, rerr = s.store.ResolveItem(workspaceID, val)
if rerr != nil || resolved == nil {
return fmt.Errorf("parent %q not found", val)
}
params.ParentLinkID = resolved.ID
} else {
params.ParentLinkID = val
resolved, _ = s.store.GetItem(val)
}
// Ensure the parent is visible — return the same not-found error for
// hidden parents so restricted users can't probe hidden item existence.
if resolved != nil {
visIDs, verr := s.visibleCollectionIDs(r, workspaceID)
if verr != nil {
return verr
}
if !isCollectionVisible(resolved.CollectionID, visIDs) {
return fmt.Errorf("parent %q not found", val)
}
}
return nil
}
// schemaHasField returns true if the collection schema defines a field with the given key.
func schemaHasField(schema models.CollectionSchema, key string) bool {
for _, f := range schema.Fields {
if f.Key == key {
return true
}
}
return false
}
func isUUID(s string) bool {
if len(s) != 36 {
return false
}
for i, c := range s {
if i == 8 || i == 13 || i == 18 || i == 23 {
if c != '-' {
return false
}
} else if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
return false
}
}
return true
}
// autoPopulateDates auto-fills start_date/end_date when status changes to active/completed.
// Only sets dates if the schema defines those date fields and the field is currently empty.
func autoPopulateDates(newFields map[string]any, existingFieldsJSON string, schema models.CollectionSchema) {
// Check if schema has date fields named start_date and/or end_date
hasStartDate := false
hasEndDate := false
for _, f := range schema.Fields {
if f.Key == "start_date" && f.Type == "date" {
hasStartDate = true
}
if f.Key == "end_date" && f.Type == "date" {
hasEndDate = true
}
}
if !hasStartDate && !hasEndDate {
return
}
// Get the new status value
newStatus, ok := newFields["status"].(string)
if !ok || newStatus == "" {
return
}
// Check if status actually changed
var oldFields map[string]any
if existingFieldsJSON != "" {
json.Unmarshal([]byte(existingFieldsJSON), &oldFields)
}
oldStatus, _ := oldFields["status"].(string)
if newStatus == oldStatus {
return
}
today := time.Now().Format("2006-01-02")
// Auto-set start_date when moving to active
if hasStartDate && newStatus == "active" {
existing, _ := newFields["start_date"].(string)
if existing == "" {
newFields["start_date"] = today
}
}
// Auto-set end_date when moving to completed
if hasEndDate && (newStatus == "completed" || newStatus == "done") {
existing, _ := newFields["end_date"].(string)
if existing == "" {
newFields["end_date"] = today
}
}
}
// parseItemListParams extracts item list parameters from the request query string.
func parseItemListParams(r *http.Request) models.ItemListParams {
params := models.ItemListParams{
Sort: r.URL.Query().Get("sort"),
GroupBy: r.URL.Query().Get("group_by"),
Search: r.URL.Query().Get("search"),
ParentID: r.URL.Query().Get("parent_id"),
Tag: r.URL.Query().Get("tag"),
AssignedUserID: r.URL.Query().Get("assigned_user_id"),
AgentRoleID: r.URL.Query().Get("agent_role_id"),
}
if r.URL.Query().Get("include_archived") == "true" {
params.IncludeArchived = true
}
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil {
params.Limit = l
}
}
if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
if o, err := strconv.Atoi(offsetStr); err == nil {
params.Offset = o
}
}
// Extract field filters: any query param that isn't a known param is a field filter.
knownParams := map[string]bool{
"sort": true, "group_by": true, "search": true, "parent_id": true,
"tag": true, "include_archived": true, "limit": true, "offset": true,
"assigned_user_id": true, "agent_role_id": true,
}
fields := make(map[string]string)
for key, values := range r.URL.Query() {
if knownParams[key] {
continue
}
if len(values) > 0 {
fields[key] = values[0]
}
}
if len(fields) > 0 {
params.Fields = fields
}
return params
}
// handleListItemActivity returns the activity feed for a specific item.
func (s *Server) handleListItemActivity(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
itemSlug := chi.URLParam(r, "itemSlug")
item, err := s.store.ResolveItem(workspaceID, itemSlug)
if err != nil {
writeInternalError(w, err)
return
}
if item == nil {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
if !s.requireItemVisible(w, r, workspaceID, item) {
return
}
params := models.ActivityListParams{
Action: r.URL.Query().Get("action"),
Actor: r.URL.Query().Get("actor"),
Source: r.URL.Query().Get("source"),
}
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil {
params.Limit = l
}
}
activities, err := s.store.ListDocumentActivity(item.ID, params)
if err != nil {
writeInternalError(w, err)
return
}
if activities == nil {
activities = []models.Activity{}
}
writeJSON(w, http.StatusOK, activities)
}