mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-21 01:53:33 +00:00
9e7daa779f
* feat: tie done-detection to the board group-by field
Closes TASK-604. Make "is this item done?" follow the collection's
settings.board_group_by rather than the hardcoded `status` key. If a
collection's board is grouped by `resolution`, then resolution's
terminal options drive dashboard counts, progress bars, changelog,
and starred-items filtering. Collections without an explicit
board_group_by (every collection today) continue to behave exactly
as before because the fallback resolves to `"status"`.
Why this shape
- No ambiguity: one field per collection wins. No reconciling
"status says in-progress, resolution says fixed."
- One JSON path to swap: every $.status query becomes
$.<done_field>. No dynamic OR across schema-discovered fields.
- Matches the mental model: the field you organize the board by is
the field that represents the item's current state. The old
mismatch (board grouped by X, "done" count from status) is a
latent bug this resolves.
- Non-breaking: board_group_by defaults to nil → DoneFieldKey
returns "status" → behavior identical to pre-TASK-604.
Model layer (internal/models/terminal.go)
- DoneFieldKey(schema, settings) resolves the done-field key with a
fallback chain: valid select on schema → that field, else "status".
- TerminalValuesForDoneField(schema, settings) returns (fieldKey,
values) honoring the done field, falling back to
DefaultTerminalStatuses when the resolved field has no
terminal_options.
- TerminalPlaceholdersForDoneField(schema, settings) is the SQL
convenience returning (fieldKey, placeholders, args).
- IsTerminalItem(fields, schema, settings) is the canonical
Go-side membership check.
- Legacy API (TerminalStatusesFromSchema, IsTerminalStatus,
TerminalStatusPlaceholders) kept as back-compat wrappers that
delegate with empty settings — resolve to "status" for callers
that don't have settings in scope yet.
SQL callers migrated to the new helpers
- internal/store/collections.go ListCollections active-count query
- internal/store/items.go GetItemProgress + GetAllItemProgress:
- New collectionDoneFilter type + childrenDoneFiltersFor{Parent,
Collection} + doneFiltersForWorkspace helpers load each
candidate collection's (schema, settings) and resolve per-
collection done keys + terminals.
- buildChildrenDoneExpr(filters, alias) compiles filters into a
single SQL boolean expression using per-collection OR clauses:
((alias.collection_id=? AND LOWER(...)
IN (?,?)) OR (alias.collection_id=? AND LOWER(...)
IN (?,?)) ...)
- Each child item is evaluated against its own collection's
done rules, so mixed-collection child progress is correct
without a global union hack.
- internal/store/agent_roles.go GetRoleBreakdown + Go-side filter
- internal/store/item_stars.go starred-items filtering now uses a
collectionDoneContext map (schema + settings) and IsTerminalItem.
Go-side callers migrated
- internal/server/handlers_dashboard.go: buildSchemaMap →
buildDoneContextMap (carries settings), isItemTerminal →
isItemDone (evaluates against the done field). 7 call sites
updated.
- internal/server/handlers_items.go: plan-progress recompute and
per-item /progress endpoint now use the done-context approach.
Left status-specific (per task scope)
- Link-payload $.status extracts in items.go getItemLink /
GetItemLinks / GetParentForItem — these populate
link.SourceStatus / link.TargetStatus, which are status-specific
by design.
- cmd/pad reconcile paths — no schema in scope, default-list
fallback is the right call.
- search.go facet "status breakdown" — a different UX concept
(bucket search results by status values) than done-detection.
Web UI reactivity
- FieldEditor: new activeDoneField prop. Each modal derives it from
boardGroupBy with the same fallback rule as the Go DoneFieldKey.
- Fields tab: the "Done?" column header on each select field renders
an "Active" green pill when that field is the board group-by, or a
muted "Saved" pill + inline hint otherwise ("Switch the board
group-by to <key> to make them drive done-detection"). Reactive to
boardGroupBy changes in the Display tab.
- DisplaySettingsEditor: "Board group by" label gets a helper line
explaining the new responsibility.
Tests
- internal/models/terminal_test.go: 13 unit tests covering fallback
resolution, placeholder args, membership (case-insensitive), and
back-compat shim semantics.
- internal/store/done_field_test.go: 3 integration tests:
1. Bugs collection grouped by resolution → items with terminal
resolution values count as done; items with status=fixed but
resolution=open do NOT count as done (proves status is no
longer consulted when it isn't the done field).
2. Collection without board_group_by still uses status terminals.
3. Mixed-collection children: each child evaluated against its
own done rules.
All pass alongside the full existing suite.
* fix: restrict done field to select (reject multi_select)
Two linked Codex P1 findings on PR #140, both rooted in the same
gap: multi_select fields store their values as JSON arrays, but both
the Go-side membership check (IsTerminalItem) and the SQL done
expression (buildChildrenDoneExpr) assume a scalar string. Naively
accepting multi_select as a done field would silently miss items
whose terminal value is one of several in the array — dashboards
and progress would report wrong counts.
Rather than implement array-containment semantics across both
paths (which would require deciding "any terminal value → done" vs
"all terminal values → done", SQL-dialect-aware JSON-contains, and
new tests for both shapes), close the gap with a constraint: only
select fields qualify as a done field. If array semantics become
a requirement later, that's a focused follow-up that can update
both paths together with a clear definition.
Changes
- DoneFieldKey and TerminalValuesForDoneField: loop bodies now
match only `select`, not `select || multi_select`. A
board_group_by pointing at a multi_select field falls back to
'status' — matching the rule for non-existent or non-select
fields.
- IsTerminalItem: docstring made the scalar contract explicit;
non-string values (which would be the multi_select array shape)
already returned false, which is now the deliberate behavior.
- buildChildrenDoneExpr: added a doc note that the scalar
JSON_EXTRACT path is correct because the upstream resolution
only hands us select fields.
- Web UI: EditCollectionModal + CreateCollectionModal derive
activeDoneField matching the backend rule (select only), and
FieldEditor.isActiveDoneField gates on field.type === 'select'.
A multi_select field never lights up the green "Active" pill now,
even if a user somehow pointed board_group_by at one.
Tests
- Replaced TestDoneFieldKey_AcceptsMultiSelect with
TestDoneFieldKey_RejectsMultiSelect. Asserts that a multi_select
board_group_by falls back to 'status' instead of being honored.
- Existing 12 unit tests + 3 integration tests all still pass.
* fix: include soft-deleted collections in done-filter loaders
Two related Codex P2s on PR #140. The done-filter loaders were
limiting their SELECT to collections with deleted_at IS NULL, but
the outer callers (GetItemProgress, GetAllItemProgress,
GetRoleBreakdown) count items regardless of their collection's
deleted_at. Net effect: after a collection was soft-deleted, its
items lost their per-collection clause in buildChildrenDoneExpr and
were always evaluated as non-terminal — undercounting done in plan
progress and inflating active counts in the role breakdown.
Fix
Drop the `c.deleted_at IS NULL` guard from all three filter
loaders:
- childrenDoneFiltersForParent
- childrenDoneFiltersForCollection
- doneFiltersForWorkspace
Soft-deleted collections still have valid schema + settings rows in
the DB, so the done rules remain applicable until a hard delete
cascades. This also matches what the outer queries count: if they
include items from a soft-deleted collection, the filter loaders
must too.
Regression test
TestGetItemProgress_HonorsSoftDeletedChildCollections:
1. Create a parent + two children in a child collection where one
child is done and one is open — assert done=1.
2. DeleteCollection on the child collection (soft-delete).
3. Re-run GetItemProgress — assert done is still 1, not 0.
Fails before the filter-loader fix, passes after.
* fix: avoid N+1 in plans progress + preserve done fallback on bad schemas
Two Codex P2s on PR #140.
P2: Avoid N+1 list-collection queries in plans progress
handlePlansProgress's restricted path was calling s.store.
ListCollections solely to build a ctxMap, but ListCollections runs a
separate active-item COUNT query per collection (collections.go),
burning O(number of collections) round-trips on every call. In
larger workspaces this materially inflates latency and can cause
timeouts. Add a lightweight Store.ListCollectionsMinimal that
returns only the ID / Schema / Settings needed for done-context
construction and skips the count queries entirely. Handler switches
to it.
P2: Preserve done fallback for unparseable collection schemas
scanCollectionDoneFilters was `continue`-ing past collections whose
schema failed to parse. Because buildChildrenDoneExpr composes a
per-collection OR clause and only applies the default-list fallback
when NO filters are constructed overall, a single malformed
collection could leave its items without a matching clause —
silently marking them as perpetually active in progress / role /
starred queries. Emit a fallback filter (status + DefaultTerminal-
Statuses) for that collection instead of skipping it, matching
pre-TASK-604 behavior for its items while still honoring the
configured rules for every other collection.
* fix: sanitize done-field keys + cover granted-item collections
Two more Codex findings on PR #140.
P1: Sanitize done-field keys before embedding SQL JSON paths
buildChildrenDoneExpr passes the resolved done-field key straight
into JSONExtractText, whose dialect implementations interpolate it
as a string literal inside `json_extract(..., '$.<key>')` /
`-->>'<key>'`. Schema / settings rows are persisted without backend-
side key validation, so a crafted board_group_by (e.g. a key with
quotes, semicolons, or SQL metacharacters) could break the
resulting query or inject. Since TASK-604 made done-field
resolution dynamic, this needs a chokepoint.
Fix: DoneFieldKey now refuses to resolve to any candidate that
doesn't match ^[a-zA-Z][a-zA-Z0-9_]*$ and falls back to the literal
"status" (which is always safe). The pattern matches the convention
already in use for search-field filtering in internal/server/
handlers_search.go.
Added TestDoneFieldKey_RejectsUnsafeKeys covering injection-shaped
strings, dots, dashes, leading digits, empty strings, and spaces.
P2: Include granted-item collections in dashboard done context
The dashboard was filtering `collections` by visibility BEFORE
building ctxMap, but allItems can still include items from
collections outside the visibility set via item-level grants
(dashItemIDs). Those items missed their own done-rules and
fell back to the status-default, misclassifying them for guests
with item-level grants in collections that use a non-status done
field.
Fix: build ctxMap from ListCollectionsMinimal(workspaceID) first —
always covering every collection in the workspace — then apply
visibility filtering to `collections` for the summary section only.
isItemDone now sees the real done rules for every item the
dashboard iterates, regardless of how visibility surfaced it.
* fix(web): mirror backend safe-key check in activeDoneField derivation
Codex P2 on PR #140. The previous commit added a safe-key regex on
the backend (DoneFieldKey rejects keys outside ^[a-zA-Z][a-zA-Z0-9_]*$
and falls back to "status"), but the Web activeDoneField derivation
in both modals only checked type === 'select'. For legacy / API-
created schemas carrying keys like `resolution-v2` or `foo.bar`, the
Fields tab would display an "Active" green pill on that field even
though the server silently ignores it and falls back to status. Users
could configure terminal options on the wrong field and never see
them take effect.
Fix: export isSafeDoneFieldKey from field-editor-types.ts (a tiny
helper wrapping the same regex the backend uses) and gate both
modals' activeDoneField derivations on it. Unsafe keys fall back to
'status' in the UI, matching the backend's behavior exactly —
Active/Saved pills are now truthful.
210 lines
8.2 KiB
Go
210 lines
8.2 KiB
Go
package models
|
|
|
|
import (
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// safeDoneFieldKey bounds what we'll interpolate into the JSON path of a
|
|
// SQL query (e.g. `json_extract(i.fields, '$.<key>')`). Collection schemas
|
|
// are persisted without backend-side key validation, so a user could in
|
|
// principle store a key containing quotes or path metacharacters; we
|
|
// refuse to resolve done-detection to anything outside this shape and
|
|
// fall back to "status". The pattern mirrors the convention already in
|
|
// use for search filters in internal/server/handlers_search.go.
|
|
var safeDoneFieldKey = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_]*$`)
|
|
|
|
// DefaultTerminalStatuses is the fallback list used when a collection's
|
|
// done field has no `terminal_options` declared on its schema. This is the
|
|
// union of all historically hardcoded terminal status values across the
|
|
// codebase.
|
|
var DefaultTerminalStatuses = []string{
|
|
"done", "completed", "resolved", "cancelled", "rejected",
|
|
"wontfix", "fixed", "implemented", "archived", "disabled", "deprecated",
|
|
}
|
|
|
|
// DoneFieldKey resolves which field on a collection's schema represents
|
|
// "is this item done?". The resolution is:
|
|
//
|
|
// 1. If CollectionSettings.BoardGroupBy names a `select` field on the
|
|
// schema, use that. This lets a collection whose board is organized
|
|
// by e.g. "resolution" naturally drive done-detection from the same
|
|
// field.
|
|
//
|
|
// 2. Otherwise, fall back to the literal key "status". Every collection
|
|
// shipped today groups by status by default, so this preserves
|
|
// existing behavior for all pre-TASK-604 collections.
|
|
//
|
|
// Only `select` (single-value) is accepted as a done field. `multi_select`
|
|
// stores its values as a JSON array, and both the Go-side membership check
|
|
// and the SQL `IN (…)` filter assume a scalar string — naively accepting
|
|
// multi_select would cause done-detection to silently miss items whose
|
|
// terminal value is one of several in the array. If array semantics are
|
|
// ever needed, they belong in a follow-up task so the Go and SQL paths
|
|
// can be updated together with a clear "any terminal value → done" rule.
|
|
//
|
|
// The function does not assume the resolved key actually exists on the
|
|
// item — callers read `items.fields[key]` and a missing field just means
|
|
// the item is treated as not terminal, which is the safe default.
|
|
func DoneFieldKey(schema CollectionSchema, settings CollectionSettings) string {
|
|
candidate := strings.TrimSpace(settings.BoardGroupBy)
|
|
if candidate == "" {
|
|
return "status"
|
|
}
|
|
// Refuse to resolve to a key that would be unsafe to embed in a
|
|
// SQL JSON path. Callers pass the returned key to dialect-specific
|
|
// JSONExtractText builders that interpolate it as a string literal;
|
|
// schema keys are not validated on write, so we validate here.
|
|
if !safeDoneFieldKey.MatchString(candidate) {
|
|
return "status"
|
|
}
|
|
for _, f := range schema.Fields {
|
|
if f.Key == candidate && f.Type == "select" {
|
|
return candidate
|
|
}
|
|
}
|
|
return "status"
|
|
}
|
|
|
|
// TerminalValuesForDoneField returns the resolved done-field key and the
|
|
// list of terminal values for that field. If the resolved field has no
|
|
// terminal_options set on the schema, falls back to DefaultTerminalStatuses
|
|
// so existing collections without schema-declared terminals continue to
|
|
// work.
|
|
func TerminalValuesForDoneField(
|
|
schema CollectionSchema,
|
|
settings CollectionSettings,
|
|
) (fieldKey string, values []string) {
|
|
fieldKey = DoneFieldKey(schema, settings)
|
|
for _, f := range schema.Fields {
|
|
// Restricted to `select` — see DoneFieldKey for why multi_select
|
|
// is deliberately rejected.
|
|
if f.Key == fieldKey && f.Type == "select" {
|
|
if len(f.TerminalOptions) > 0 {
|
|
return fieldKey, f.TerminalOptions
|
|
}
|
|
break
|
|
}
|
|
}
|
|
return fieldKey, DefaultTerminalStatuses
|
|
}
|
|
|
|
// TerminalPlaceholdersForDoneField is a SQL-layer convenience that returns
|
|
// the done-field key plus the placeholder + args pair needed for an IN
|
|
// clause. All values are lowercased to match the WHERE clause pattern used
|
|
// across the codebase (LOWER(json_extract(...)) IN (?, ?, ?)).
|
|
func TerminalPlaceholdersForDoneField(
|
|
schema CollectionSchema,
|
|
settings CollectionSettings,
|
|
) (fieldKey string, placeholders string, args []any) {
|
|
key, values := TerminalValuesForDoneField(schema, settings)
|
|
ph := make([]string, len(values))
|
|
ar := make([]any, len(values))
|
|
for i, v := range values {
|
|
ph[i] = "?"
|
|
ar[i] = strings.ToLower(v)
|
|
}
|
|
return key, strings.Join(ph, ","), ar
|
|
}
|
|
|
|
// IsTerminalItem reports whether an item's fields map indicates the item
|
|
// is in a terminal state for its collection. This is the canonical Go-side
|
|
// "is done" check when the caller has both the item's parsed fields and
|
|
// the collection's schema + settings in scope.
|
|
//
|
|
// The value at the resolved done-field key is expected to be a scalar
|
|
// string — DoneFieldKey only resolves to `select` fields, which round-
|
|
// trip through the fields JSON as a single string. Non-string values
|
|
// (e.g. an array from a misconfigured multi_select) return false rather
|
|
// than trying to infer semantics.
|
|
func IsTerminalItem(
|
|
itemFields map[string]any,
|
|
schema CollectionSchema,
|
|
settings CollectionSettings,
|
|
) bool {
|
|
key, values := TerminalValuesForDoneField(schema, settings)
|
|
raw, ok := itemFields[key]
|
|
if !ok {
|
|
return false
|
|
}
|
|
s, ok := raw.(string)
|
|
if !ok {
|
|
return false
|
|
}
|
|
lower := strings.ToLower(s)
|
|
for _, v := range values {
|
|
if strings.ToLower(v) == lower {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ── Back-compat wrappers ────────────────────────────────────────────────
|
|
// The original API (status-only) stays in place so callers that don't yet
|
|
// have CollectionSettings in scope keep working unchanged. Internally each
|
|
// of these delegates to the new done-field-aware implementation with
|
|
// empty settings — which resolves the done field to "status", matching
|
|
// pre-TASK-604 behavior byte-for-byte.
|
|
|
|
// TerminalStatusesFromSchema extracts terminal status options from a
|
|
// CollectionSchema. If the `status` field has TerminalOptions set, returns
|
|
// those. Otherwise returns DefaultTerminalStatuses.
|
|
//
|
|
// Deprecated: prefer TerminalValuesForDoneField when settings are in
|
|
// scope. This wrapper forces done-field resolution to the literal
|
|
// "status" key; callers that want to honor the collection's configured
|
|
// board_group_by should migrate to the settings-aware API.
|
|
func TerminalStatusesFromSchema(schema CollectionSchema) []string {
|
|
_, values := TerminalValuesForDoneField(schema, CollectionSettings{})
|
|
return values
|
|
}
|
|
|
|
// IsTerminalStatus checks whether a status string is terminal given a
|
|
// schema. Like the status extract above, this is hardcoded to the `status`
|
|
// field — it takes a pre-extracted status string and checks membership
|
|
// against that field's terminal options. Use when you already know you're
|
|
// working with the status field specifically (e.g. link-payload joins).
|
|
func IsTerminalStatus(status string, schema CollectionSchema) bool {
|
|
lower := strings.ToLower(status)
|
|
for _, ts := range TerminalStatusesFromSchema(schema) {
|
|
if strings.ToLower(ts) == lower {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// IsTerminalStatusDefault checks using the default fallback list (for
|
|
// cases where no collection schema is available).
|
|
func IsTerminalStatusDefault(status string) bool {
|
|
lower := strings.ToLower(status)
|
|
for _, ts := range DefaultTerminalStatuses {
|
|
if ts == lower {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// TerminalStatusPlaceholders returns a comma-separated placeholder string
|
|
// and the corresponding args slice for use in SQL IN clauses. Like the
|
|
// *FromSchema variant it is hardcoded to the `status` field; prefer
|
|
// TerminalPlaceholdersForDoneField when settings are in scope.
|
|
func TerminalStatusPlaceholders(schema CollectionSchema) (string, []any) {
|
|
_, placeholders, args := TerminalPlaceholdersForDoneField(schema, CollectionSettings{})
|
|
return placeholders, args
|
|
}
|
|
|
|
// DefaultTerminalStatusPlaceholders returns placeholders and args for the
|
|
// default terminal statuses list.
|
|
func DefaultTerminalStatusPlaceholders() (string, []any) {
|
|
placeholders := make([]string, len(DefaultTerminalStatuses))
|
|
args := make([]any, len(DefaultTerminalStatuses))
|
|
for i, s := range DefaultTerminalStatuses {
|
|
placeholders[i] = "?"
|
|
args[i] = s
|
|
}
|
|
return strings.Join(placeholders, ","), args
|
|
}
|