mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-24 19:32:10 +00:00
e5eae5e94e
* 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.
1284 lines
38 KiB
Go
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, ¶ms); 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, ¶ms, 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), ¤tFields); 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)
|
|
}
|