Commit Graph

151 Commits

Author SHA1 Message Date
xarmian e0f3583333 feat(cli): categorized template picker + interactive select (TASK-616) (#148)
* feat(cli): categorized template picker + interactive select (TASK-616)

Turns the CLI template picker from a flat alphabetical dump into a
category-aware flow that reflects the Software / People / Research /
Content / Operations / Personal taxonomy established by PLAN-609.

Library
-------
- collections.GroupTemplatesByCategory returns visible templates
  bucketed into CategoryOrder with a trailing slot for any
  custom-category templates — one canonical grouping that both CLI
  and the upcoming web picker (TASK-617) can consume.
- collections.CategoryLabel turns category slugs into display labels
  ("software" → "Software") with passthrough for unknown values.
- collections.CategoryOrder exposes the canonical display order.

CLI
---
- New cmd/pad/templates_picker.go defines:
  - printGroupedTemplates: writes the grouped listing with icons,
    aligned columns, and a dim "(default)" marker on startup.
  - pickTemplateInteractive: prompts when the user hasn't passed
    --template and is on a TTY. Accepts a number OR a template name;
    enter selects the default (startup); invalid input re-prompts.
  - canPromptForTemplate: TTY detection so scripts never block.
- pad workspace init --list-templates now uses the grouped printer.
- pad workspace init / pad init error messages for unknown templates
  show the grouped list instead of a flat dump.
- pad init now triggers pickTemplateInteractive when no --template
  flag is set AND stdin/stdout are TTYs. Non-TTY invocations fall
  back to the "startup" default unchanged.
- --template flag help no longer hardcodes "startup, scrum, product"
  since the list now grows with non-software templates.

Tests
-----
- Library: TestGroupTemplatesByCategory (canonical order, no hidden,
  every visible template assigned), TestCategoryLabel.
- CLI: TestPickTemplateInteractiveDefault / ByName / ByNumber /
  RetriesOnInvalid, TestPrintGroupedTemplatesIncludesEveryVisibleTemplate
  (smoke: every visible template renders, demo hidden, category
  headers present).

Parent: PLAN-609.

* fix(cli): propagate non-EOF prompt read errors in template picker

Per Codex review on PR #148. pickTemplateInteractive previously
mapped any ReadString error to silently selecting the default
template. A detached PTY returning EIO or a similar read failure
would quietly create a workspace with the startup template even
though the user never made a valid choice. Restrict the silent
fallback to io.EOF (which is benign for pipes, tests, closed
stdin) and bubble up any other error so the command aborts.

* test(cli): cover non-EOF read error path in template picker

Adds TestPickTemplateInteractiveSurfacesNonEOFReadErrors to verify
the behavior change from the previous commit — a non-EOF read
failure propagates up instead of silently selecting the default
template.
2026-04-18 06:31:24 -04:00
xarmian fed365d914 feat(templates): ship interviewing template (TASK-615) (#147)
* feat(templates): ship interviewing template (TASK-615)

Candidate-side companion to the hiring (company-side) template. Same
People category, near-zero collection overlap — a real proof that
one category can hold two templates with barely-related schemas.

Collections
-----------
- Applications (APP) — roles being tracked, stages from researching →
  applied → screen → interviewing → offer → accepted / rejected /
  withdrawn
- Interviews (INT) — individual rounds, child of an Application,
  with round type, format, date, prep_status (including completed)
- Companies (CO) — standalone research notes on companies, referenced
  from Applications via wiki-link so notes are persistent even across
  multiple Applications at the same company
- Contacts (CON) — referrals, recruiters, interviewers — tracked
  independently for followup hygiene
- Docs, Conventions, Playbooks

Trigger vocabularies
--------------------
- InterviewingConventionTriggers: always, on-application-submitted,
  on-interview-scheduled, on-interview-completed, on-stage-change,
  on-offer-received, on-rejection, weekly-review
- InterviewingPlaybookTriggers: on-application-submitted,
  on-interview-scheduled, on-interview-completed, on-stage-change,
  weekly-review, manual
- Scopes: all, research, applications, interviews, followups

Starter pack
------------
- Conventions (3): 48h prep notes (should/on-interview-scheduled),
  end-of-application retros (should/on-stage-change), 24h thank-yous
  (should/on-interview-completed)
- Playbooks (3): Log an Interview, Weekly Job Search Review,
  Interviewing Workspace Onboarding
- Seed items (2): one example Application, one example Company

Tests
-----
- TestInterviewingTemplate — collections present, interviewing
  triggers present and distinct from both software AND hiring triggers
- TestSeedCollectionsFromTemplateInterviewing — end-to-end: seven
  collections created, starter pack populated, prefixes correct

Parent: PLAN-609.

* fix(templates): mark interviewing Companies.closed as terminal

Per Codex review on PR #147. The Companies status field had a
'closed' option but no TerminalOptions declared, so done-state
resolution fell back to the global default set which doesn't
include 'closed' — closed companies were being counted as active
in dashboard and progress views. Adding TerminalOptions fixes
lifecycle metrics without changing user-facing options.

* fix(templates): Log-an-Interview playbook uses only interviewing collections

Per Codex review iteration 2 on PR #147. The playbook step
referenced 'Create a follow-up Task' but the interviewing
template doesn't ship a tasks collection. Rewrote the step to
use the collections that actually exist — log the thank-you as
a comment on the Interview and update the matching Contact's
last_contact. Keeps the starter playbook consistent with the
schema it ships alongside.
2026-04-18 06:10:27 -04:00
xarmian b891ba84a4 feat(templates): ship hiring template (TASK-614) (#146)
* feat(templates): ship hiring template (TASK-614)

First non-software template under PLAN-609. Proves the machinery
built by TASK-610 through TASK-613 end-to-end: category grouping,
per-template trigger vocabularies, template-owned starter packs,
domain-specific seed items.

Collections
-----------
- Requisitions (REQ) — open roles, with status/team/level/location
- Candidates (CAND) — applicants, parent-linked to a Requisition
- Interview Loops (LOOP) — interview rounds, parent-linked to a Candidate
- Feedback (FB) — per-interviewer debriefs, parent-linked to a Loop
- Docs — rubrics, process notes
- Conventions + Playbooks — using hiring trigger vocabulary

Trigger vocabularies
--------------------
- HiringConventionTriggers: always, on-candidate-advance,
  on-loop-scheduled, on-feedback-submitted, on-offer-extended,
  on-close-requisition
- HiringPlaybookTriggers: on-candidate-advance, on-interview-scheduled,
  on-feedback-submitted, on-close-requisition, manual
- HiringConventionScopes / HiringPlaybookScopes: all, sourcing,
  screening, interviewing, offers

Starter pack
------------
- Conventions (3): PII handling (must/always), requisition linking
  (should/always), 24h debriefs (should/on-feedback-submitted)
- Playbooks (2): "Advance a Candidate" (on-candidate-advance),
  "Hiring Workspace Onboarding" (manual)
- Seed items: one example Requisition, one example Candidate (both
  labeled as seeded so users can delete or overwrite)

Tests
-----
- TestHiringTemplate — collections present, trigger vocabulary uses
  hiring values and does not leak software triggers (on-commit etc.)
- TestSeedCollectionsFromTemplateHiring — end-to-end: seeding
  creates all seven collections plus populates the starter pack

Parent: PLAN-609.

* fix(web): display hiring triggers on conventions + playbooks pages

Per Codex review P1 on PR #146. The conventions page hardcoded a
software-only TRIGGERS list in its grouping loop, silently hiding
any convention whose trigger wasn't in that list — so a hiring
workspace's seeded conventions (on-candidate-advance etc.) never
appeared in the primary management UI. Same issue on the playbooks
page's filter dropdown.

- conventions page: grouping now iterates the union of the hardcoded
  TRIGGERS (in original order) plus any triggers discovered in the
  data (sorted alphabetically). Unknown triggers fall back to a
  generic bell icon + the raw trigger string via a triggerMeta helper.
  byTrigger is now SvelteMap<string, Item[]>; the narrow Trigger type
  still gates the create form.
- playbooks page: the filter dropdowns for trigger and scope now
  expose the union of the hardcoded list and any distinct values
  found on loaded playbooks. Create form still uses the narrow list.

The broader "derive options from collection schema so the CREATE
forms also follow the workspace's trigger vocabulary" is tracked
as IDEA-619 for a follow-up PR.

* fix(templates): ship explicit prefixes for hiring collections

Per Codex P2 on PR #146. The seeded candidate's content referenced
--parent REQ-1, but the default DerivePrefix turns "Requisitions"
into "REQUI" (strips trailing S, caps at 5), so the example
wouldn't resolve. Similar problems for Candidates (CANDI) and
Feedback (FEEDB).

- Add optional Prefix string field to DefaultCollection so templates
  can override the derived prefix. Empty (the default) preserves
  today's auto-derivation for every existing template.
- Thread DefaultCollection.Prefix through SeedCollectionsFromTemplate
  to the CollectionCreate call — the CreateCollection API already
  supported a Prefix field.
- Hiring template sets explicit prefixes: Requisitions → REQ,
  Candidates → CAND, Interview Loops → LOOP, Feedback → FB. The
  seeded onboarding text's --parent REQ-1 reference now resolves.
- Test asserts the expected prefixes land on the created collections.

* fix: add 'offers' to hiring playbook scopes + tolerate custom scopes in web UI

Per Codex review iteration 3 on PR #146.

- HiringPlaybookScopes now includes 'offers', matching
  HiringConventionScopes. The hiring pipeline has a distinct offer
  stage and playbook workflows tied to offer management were
  previously uncovered in the schema.
- web/conventions page: the scope filter dropdown now exposes the
  union of SURFACES (original order) plus any scopes discovered on
  loaded conventions, via a new allSurfaces $derived. Same pattern
  as the earlier triggers fix. Non-software scopes (sourcing,
  screening, interviewing, offers) now show up for hiring
  workspaces instead of being hidden by the narrow hardcoded list.

The conventions create form still uses the hardcoded SURFACES — that
broader "derive from collection schema" fix is tracked as IDEA-619.

* fix(templates): hiring Feedback BoardGroupBy uses submitted, not recommendation

Per Codex review iteration 4 on PR #146. Feedback items have two
select fields: recommendation (strong-hire/hire/mixed/no-hire/
strong-no, no terminal values) and submitted (pending/submitted,
terminal=submitted). The done-state pipeline prefers
settings.board_group_by when it's a select field, so grouping on
recommendation made terminal detection fall back to checking
recommendation against default done-statuses — never matching,
leaving submitted feedback perpetually 'active' in active-count
views. Group on submitted so completion actually registers.

* fix(templates): Advance-a-Candidate playbook uses valid Feedback fields

Per Codex review iteration 5 on PR #146. The seeded playbook told
agents to create Feedback items with recommendation=pending, but
recommendation's allowed values are only the concrete verdicts
(strong-hire, hire, mixed, no-hire, strong-no) — 'pending' would be
rejected at field validation. Updated the step to use
submitted=pending (which IS in the allowed options) and call out
that recommendation should stay blank until the interviewer
actually records a verdict.
2026-04-18 05:38:33 -04:00
xarmian 115b33849e feat(templates): software starter pack + idempotent seeding (TASK-612) (#144)
* feat(templates): software starter pack + idempotent seeding (TASK-612)

Ship the software templates (startup, scrum, product) with a curated
starter pack of conventions + playbooks so new workspaces feel
"batteries included" rather than empty shells. The pack is a safe,
small subset drawn from the existing convention/playbook library —
the library itself remains the full catalog for interactive onboarding.

Starter pack contents
---------------------
Conventions (4):
- Conventional commit format (on-commit, should)
- Never push directly to main (on-commit, must)
- Run tests before completing tasks (on-task-complete, must)
- Review your own changes before PR (on-pr-create, should)

Playbooks (2):
- Implementation Workflow (on-implement)
- Code Review Process (on-review)

The pack is materialized by looking up library items by title and
converting them to SeedConvention / SeedPlaybook via json.Marshal of
the expected field shape. When the library's wording changes, the
template's seed content changes automatically.

Store-side changes
------------------
SeedCollectionsFromTemplate is now idempotent with respect to seed
items: items are only created in collections that were freshly
created during the current call (tracked via a freshlyCreated set).
That's the invariant that lets the server's startup auto-upgrade
safely re-run on every boot without duplicating items across every
workspace in the DB.

Empty template name preserves the old behavior (default collections,
no starter pack) — this keeps backward compatibility for callers that
don't pass a template, including the server-startup auto-upgrade path
and all existing server tests. Explicit "startup" / "scrum" / "product"
now gets the starter pack.

Tests
-----
- TestSoftwareStarterPacksPopulated — guards against library-title drift
- TestSoftwareTemplatesShipStarterPacks — each software template ships a pack
- TestSeedCollectionsFromTemplateSeedsStarterPack — end-to-end seeding works
- TestSeedCollectionsFromTemplateIdempotentWithSeedItems — re-seed doesn't duplicate

Parent: PLAN-609.

* fix(cli): default pad init to startup template when --template is omitted

Per Codex review on PR #144. Without this, `pad workspace init` without
`--template` no longer seeded the starter pack, even though startup is
documented as the default. The fix lives in ensureWorkspace (shared by
both init.go and the workspace creation command in main.go) — empty
flag is rewritten to "startup" there. Tests and other direct API
callers that want an empty workspace still pass Template="" through.

* fix(cloud): auto-create workspace passes startup template for starter pack

Per Codex review iteration 2 on PR #144. The auto-create cloud-signup
flow calls SeedCollectionsFromTemplate with an empty template, which
after this PR's semantics meant new cloud workspaces got no starter
conventions/playbooks. Pass "startup" explicitly to match the CLI
init behavior.

* fix(store): propagate collection lookup errors during seeding

Per Codex review iteration 3 on PR #144. seedItem previously treated
any error from GetCollectionBySlug as a silent no-op, which hid real
DB lookup failures — a transient error during workspace creation would
make seeding appear successful while conventions/playbooks were in
fact missing. Now we distinguish the two cases:

  - err != nil  → propagate so callers can detect partial init
  - coll == nil → benign (template references a slug not in its
                   collections list; template-author bug, no-op)

* fix(store): idempotent seeding by item title (partial-init recovery)

Per Codex review iteration 4 on PR #144. The previous design gated
item seeding on collections being freshly-created-in-this-call, which
trapped partially-initialized workspaces: if a DB error fired between
collection creation and item seeding, a retry would see the
collections already existed and skip every remaining seed item.

Switch to title-based idempotency. Before inserting a seed item we
list the target collection's existing items (once per collection, via
a small cache) and skip any whose title already exists. That makes
seeding:

- Idempotent: re-running a template doesn't duplicate items
- Recoverable: retrying fills in missing items after partial init
- Retry-safe: the auto-upgrade path can re-run safely on every boot

New test TestSeedCollectionsFromTemplateRecoversPartialInit exercises
the recovery path explicitly.
2026-04-18 01:22:08 -04:00
xarmian d1c7ede735 feat(templates): parameterize conventions & playbooks schemas (TASK-611) (#143)
The Conventions and Playbooks collections currently hardcode their
trigger and scope select options to a software-centric vocabulary
(on-commit, on-pr-create, backend, frontend, ...). That makes it
impossible for a non-software template to ship domain-specific
triggers like on-candidate-advance or on-interview-scheduled, which
is a hard blocker for PLAN-609.

- Change conventionsCollection() and playbooksCollection() to accept
  trigger + scope option lists from the caller.
- Export SoftwareConventionTriggers, SoftwareConventionScopes,
  SoftwarePlaybookTriggers, SoftwarePlaybookScopes as the canonical
  software-domain defaults. Non-software templates pass their own
  lists.
- Defensively copy the caller's slices inside the helpers so a
  template package author cannot accidentally mutate a shared
  option list.
- Update scrum + product templates to pass the software defaults.
- Refactor Defaults() in defaults.go to use the same parameterized
  helpers — eliminates ~85 lines of duplicated schema definition
  that had drifted out of sync with the templates.go copy.
- Add tests covering: caller option propagation, defensive copying,
  and software-template invariants.

Parent: PLAN-609.
2026-04-18 00:31:55 -04:00
xarmian 73a6e1f3a9 feat(templates): categorize WorkspaceTemplate + hide demo (TASK-610) (#142)
Refactor the WorkspaceTemplate struct to carry the metadata and domain-
specific seed packs needed for the upcoming non-software templates.

- Add Category, Icon, Hidden, Conventions, Playbooks fields to the
  WorkspaceTemplate struct. Existing fields (Name, Description,
  Collections, SeedItems) unchanged.
- Define SeedConvention and SeedPlaybook types so templates can carry
  domain-specific rules and workflows (populated in a follow-up task).
- Introduce category constants (software, people, research, content,
  operations, personal).
- Assign Category=software and Icon to startup (🚀), scrum (🏃),
  product (📦). Mark demo (🎬) as Hidden so it no longer appears in
  the picker while remaining buildable by explicit --template demo.
- Split ListTemplates() into a filtered picker view and a new
  ListAllTemplates() for internal tooling.
- Expose category and icon on the /workspaces/templates API response
  so the web picker can group by category in a follow-up task.
- Add package tests for hidden-filtering and picker metadata
  invariants (the package previously had no tests).

Parent: PLAN-609.
2026-04-18 00:24:22 -04:00
xarmian 9e7daa779f feat: tie done-detection to the board group-by field (TASK-604) (#140)
* 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.
2026-04-17 21:45:55 -04:00
xarmian be0ae3d8f5 Revert "fix: accept password confirmation at unlink for unupgraded users"
This reverts commit c94fc8dd6b.
2026-04-17 04:41:35 +00:00
xarmian c94fc8dd6b fix: accept password confirmation at unlink for unupgraded users
BUG-588 follow-up: users who signed up with email/password and later
linked a single OAuth provider are backfilled as password_set=false
(because the backfill only flags users with no OAuth linked), and if
they're currently logged in via OAuth they can't complete the
ValidatePassword path that would flip the bit. They remain blocked
from unlinking that only provider.

Accept an optional password in the /auth/oauth-unlink request body.
When the user has no other sign-in method and password_set is still
false, the handler verifies the supplied password via ValidatePassword
(which also upgrades password_set on success) and then allows the
unlink. No password supplied → same "cannot unlink your only sign-in
method" error as before, with a slightly more actionable message.
2026-04-17 04:37:11 +00:00
xarmian e328844a1b fix: resolve five open bugs (BUG-585, BUG-586, BUG-588, BUG-589, BUG-590)
BUG-585 — Code-block copy no longer includes ``` fences
  Editor.svelte: ProseMirror plugin overrides copy/cut when the selection
  is inside a code_block node and writes raw textBetween to the clipboard.
  NodeView for non-mermaid code blocks now shows a hover "Copy" button that
  uses the existing copyToClipboard() util (with execCommand fallback).

BUG-586 — Wiki-link picker matches on item ref
  Editor.svelte: getFilteredLinks() now also matches formatItemRef(item),
  so typing [[DOC-535]] finds items by their issue ID. Picker dropdown
  shows the ref as a badge; {#each} key switched to doc.id so duplicate
  titles across collections don't collide.

BUG-588 — Can unlink OAuth provider when password is configured
  Adds a password_set column to track whether a user has a usable
  password vs. the random placeholder hash given to OAuth users.
  CreateUser sets it true, UpdateUser sets it true when a password is
  provided, and ValidatePassword auto-upgrades it on any successful
  email/password login (which transparently upgrades pre-existing users
  who linked OAuth after signing up with a real password — the OAuth
  placeholder hash cannot match user-supplied plaintext, so this is safe).
  handleOAuthUnlink now permits removing the last provider when
  user.HasPassword() is true.

BUG-589 — Pre-auth pages render standalone
  +layout.svelte: isAuthPage now also matches /forgot-password and
  /reset-password/* so those pages don't inherit the authenticated
  sidebar/topbar layout.

BUG-590 — Search no longer crashes with null results
  store.Search() returned a nil Results slice on no-match queries, which
  Go marshals as JSON null; CommandPalette then crashed on results.length.
  Backend now normalizes nil to []SearchResult{} before returning.
  CommandPalette also coalesces resp.results ?? [] on the initial search
  and loadMore paths as belt-and-suspenders hardening.
2026-04-17 03:03:49 +00:00
xarmian 8351b8194f feat: add faceted counts to search results (#125)
* feat: add faceted counts to search results

Add collection and status faceted counts to SearchResponse so the
frontend can show breakdowns like "Tasks (24) · Ideas (4)". Facets
reflect the full unpaginated result set via two GROUP BY queries
using the same filters as the main search.

- Add SearchFacets type with collections and statuses maps
- Add searchFacets() method using appendSearchFilters for consistency
- Add SearchFacets TypeScript type to frontend
- Add TestSearchFacets covering counts and pagination independence

* fix: include ref-hit items in facet counts

Ref hits (e.g. searching "TASK-42") bypass FTS and wouldn't appear
in FTS-based facet aggregation. Merge them into facets after the
facet queries run so collection/status counts include all results.

Addresses codex review on PR #125.

* fix: remove ref-hit facet merge to avoid double-counting

Unconditionally adding ref hits to facets overcounts when the item
is also found by FTS (the common case). Since we can't cheaply
detect overlap, leave facets as FTS-only. Ref searches typically
return 1 exact match, so the off-by-one is acceptable.

Addresses codex review on PR #125.
2026-04-15 07:37:07 -04:00
xarmian 3e51bfa541 fix: rewrite search ref-hit pagination for correctness (#124)
* fix: rewrite search ref-hit pagination for correctness

The previous ref-hit pagination logic had cascading issues: clearing
results on offset>0 broke the seen map, total was wrong for ref
queries on later pages, and multi-ref hits were dropped entirely.

Rewrite the approach:
- Save ref hits and their IDs before FTS query runs
- Ref hits always appear on page 0; FTS limit reduced accordingly
- On pages after 0, ref hits excluded and FTS offset adjusted
- Track FTS deduplication to correct total (avoid double-counting)
- Total is always >= actual result count as a safety floor
- All ORDER BY clauses include i.id tie-breaker for stable pagination

Addresses all 6 codex review comments on PR #123.

* fix: simplify ref-hit total calculation

Remove the refCount add / ftsDeduped subtract dance which was
inherently broken across pages. Instead, use a simple floor:
total is always at least len(results). The FTS count is accurate
for FTS results; ref-only hits (rare) just bump the floor.

Addresses codex review on PR #124.
2026-04-15 00:04:51 -04:00
xarmian 999bd3cfca feat: add pagination and sorting to search API (#123)
* feat: add pagination and sorting to search API

Extend the search endpoint with limit/offset pagination and sort options.
The response now includes total count (from a separate count query) so
frontends can paginate properly.

- Add Limit, Offset, Sort, Order to SearchParams with Normalize() defaults
- Return SearchResponse struct with total/limit/offset metadata
- Count query runs alongside results query for accurate totals
- Sort options: relevance (default), created_at, updated_at, title
- Add --sort, --limit, --offset flags to CLI search command
- Update frontend SearchFilters and SearchResponse types
- Add TestSearchPagination and TestSearchSorting integration tests

* fix: count ref hits in search totals and handle empty pages

- Ensure total is never less than actual results when direct ref
  matches (e.g. "TASK-5") aren't captured by the FTS count query
- Handle empty page in CLI output: show "No results on this page"
  instead of an invalid descending range like "Showing 11-10 of 5"

Addresses codex review on PR #123.

* fix: paginate ref hits correctly and add sort tie-breaker

- Ref hits now occupy slots on page 0 only; FTS limit/offset adjusted
  so combined results respect the requested pagination contract
- On subsequent pages, ref hits are excluded (already shown on page 0)
- Add i.id as deterministic tie-breaker to all ORDER BY clauses to
  prevent duplicate/missing items across paginated pages

Addresses codex review on PR #123.
2026-04-14 23:40:23 -04:00
xarmian aef0e2326a feat: add collection and field filtering to search API (#122)
* feat: add collection and field filtering to search API

Extend the /search endpoint to support scoping by collection slug and
filtering by structured field values (status, priority, and generic
field.* params). Works on both SQLite FTS5 and PostgreSQL tsvector.

- Add Collection and FieldFilters to SearchParams (store layer)
- Parse collection, status, priority, field.* query params (handler)
- Add SearchFilters type and update api.search() signature (frontend)
- Add --collection, --status, --priority flags to CLI search command
- Add integration tests for collection, field, and combined filtering

* fix: validate field filter keys to prevent SQL injection

Reject field filter keys containing special characters before they
reach JSONExtractText, which interpolates keys directly into SQL.
Keys must match ^[a-zA-Z][a-zA-Z0-9_-]*$ — validation is applied
in both the handler and the store layer as defense in depth.

Addresses codex review on PR #122.
2026-04-14 22:31:18 -04:00
xarmian c5183d2a4b feat: add SSE events for item star/unstar (#121)
* feat: add SSE events for item star/unstar

Emit real-time events for multi-tab sync (PLAN-564, TASK-571):

- New event types: item_starred, item_unstarred
- Emitted from handleStarItem and handleUnstarItem after success
- Includes item ID, title, collection, actor, and source
- Follows existing publishItemEventWithName pattern

* fix: scope star/unstar SSE events to the acting user

Star events are user-specific state, not workspace-wide. Changes:

- Add UserID field to Event struct for user-scoped events
- SSE handler filters events with UserID, only delivering them to
  the user who triggered the action (multi-tab sync without leaking
  star actions to other workspace members)
- Star/unstar handlers set UserID when publishing events
2026-04-14 21:03:37 -04:00
xarmian 9072e49b17 feat: add CLI commands for item starring (#120)
Add star/unstar/starred CLI commands (PLAN-564, TASK-570):

- pad item star <ref> — star an item
- pad item unstar <ref> — unstar an item
- pad item starred [--all] [--format json] — list starred items

Client methods: StarItem, UnstarItem, ListStarredItems.
2026-04-14 20:51:03 -04:00
xarmian f7d93d878d feat: add starred items to dashboard (#119)
* feat: add starred items to dashboard

Add starred items section to the dashboard (PLAN-564, TASK-569):

- Dashboard API: fetches non-terminal starred items for the current user,
  applies RBAC visibility filtering, returns as starred_items array
- TypeScript types: add starred_items to DashboardResponse
- Dashboard UI: renders starred items section with card grid, item refs,
  status pills, and "View all" link to /starred page

* fix: cap starred items in dashboard response to 10

Match the same limit applied to active_items, preventing large payloads
on the polling dashboard endpoint for users with many starred items.
2026-04-14 20:31:37 -04:00
xarmian 77d3fe2d72 feat: add Starred sidebar entry and starred items page (#118)
* feat: add Starred sidebar entry and starred items page

Add dedicated starred items view (PLAN-564, TASK-568):

- Sidebar: new " Starred" link below Activity, with active state
- Starred page: shows user's starred items grouped by collection
- "Show completed" toggle to include/exclude terminal status items
- Loading skeleton, empty state with usage instructions
- Excludes "starred" and "roles" from collection slug detection

* fix: reactively remove unstarred items, guard against stale responses

Two fixes for the starred page:

1. Items list is now derived from starredStore.isStarred, so unstarring
   an item via the ItemCard toggle immediately removes it from the page
   without a refetch.

2. Request sequencing via loadSeq counter prevents stale responses from
   overwriting the UI when rapidly toggling "Show completed".

* fix: reserve collection slugs that collide with workspace UI routes

Prevent collections from being created or renamed to slugs that shadow
workspace-level routes (settings, activity, roles, starred, library,
new). If a reserved slug is generated, "-collection" is appended
(e.g. "starred" becomes "starred-collection").

Also fixes starred page: items list is now reactive to unstar actions,
and loadStarred uses request sequencing to prevent stale responses.

* fix: skip store filter on starred page until store is loaded

Trust the API response when starredStore hasn't loaded yet, since
/starred only returns starred items. Apply the reactive filter only
after the store is loaded, so unstar actions still remove items
immediately but initial render isn't broken by async timing.
2026-04-14 20:15:49 -04:00
xarmian 844e40f0a9 feat: add star/unstar API endpoints (#116)
* feat: add star/unstar API endpoints

Add REST API for item starring (PLAN-564, TASK-566):

- POST /workspaces/{ws}/items/{slug}/star — star item (idempotent, 204)
- DELETE /workspaces/{ws}/items/{slug}/star — unstar item (204 or 404)
- GET /workspaces/{ws}/items/{slug}/star — check star status ({"starred": bool})
- GET /workspaces/{ws}/starred — list starred items (?include_terminal=true)

All endpoints are scoped to the authenticated user, check item visibility
via RBAC/grants, and enrich list responses with parent links and refs.

* fix: enforce RBAC visibility filtering on starred items list

Apply the same collection/item grant filtering used by handleListItems
to handleListStarredItems. Without this, guests or restricted members
could see starred items from collections they no longer have access to.
2026-04-14 18:15:19 -04:00
xarmian d372be6aff feat: add item_stars table and store methods (#115)
* feat: add item_stars table and store methods for per-user item starring

Add the data layer for item starring/favorites (PLAN-564, TASK-565):

- Migration 042 (SQLite) / 022 (PostgreSQL): item_stars join table with
  (user_id, item_id) primary key, ON DELETE CASCADE, and indexes
- Store methods: StarItem, UnstarItem, IsItemStarred, AreItemsStarred
  (batch), ListStarredItems (enriched), CountStarredItems, DeleteStarsForItem
- 8 tests covering CRUD, idempotency, per-user isolation, and batch ops

* fix: implement includeTerminal filter in ListStarredItems

The includeTerminal parameter was accepted but unused — starred items
in terminal statuses (done, completed, etc.) were always returned.
Now post-filters using IsTerminalStatusDefault, matching the pattern
used by GetRoleBoardItems. Adds test coverage for the filter.

* fix: use per-collection schemas for terminal filtering, cascade user deletes

Addresses two code review findings:

1. Terminal filtering now loads collection schemas and uses
   IsTerminalStatus per collection instead of IsTerminalStatusDefault.
   This correctly handles custom terminal statuses (e.g. "closed").

2. Added ON DELETE CASCADE to the user_id foreign key in both SQLite
   and PostgreSQL migrations, so deleting a user automatically cleans
   up their stars.

* perf: use lightweight query for collection schema loading

Replace ListCollections call in buildCollectionSchemaMap with a direct
SELECT of only id and schema columns. ListCollections runs per-collection
COUNT(*) queries for ActiveItemCount which are unnecessary here.
2026-04-14 17:50:48 -04:00
xarmian 6a552b83ae fix: link comment to activity record to prevent duplicate timeline entries (#114)
* fix: link comment to activity record to prevent duplicate timeline entries (#BUG-563)

When creating a comment, the handler created a comment (activity_id=NULL) and a
separate "commented" activity but never linked them. The timeline dedup logic only
filters activities that have a linked comment, so both showed up as separate entries.

Fix: create the activity first via logActivityWithMetaReturningID, then pass its ID
to CreateComment so buildTimeline correctly deduplicates them.

* fix: only link activity ID to comment when activity insert succeeds

Address review feedback: CreateActivity assigns an ID before Exec and
returns it even on insert failure. Since comments.activity_id has a FK
constraint, setting a dangling reference would break comment creation.
Now we only set ActivityID when the activity was actually persisted.
2026-04-14 16:21:21 -04:00
xarmian ba01d95111 feat: add web UI for TOTP 2FA setup in user settings (#109)
* feat: add web UI for TOTP 2FA setup in user settings

Add a Two-Factor Authentication section to the console settings page
so users can enable/disable TOTP 2FA from the browser. The backend
API already existed (PR #77); this wires up the frontend.

- Add 2FA section to console settings with enable/disable flows
- Enable flow: QR code + manual secret + verification code input
- Recovery codes displayed with copy/download after setup
- Disable flow: password confirmation modal
- Add totp.setup/verify/disable methods to API client
- Add TOTP types (TOTPSetupResponse, TOTPVerifyResponse, etc.)
- Add totp_enabled to User type and /auth/me response
- Add qrcode npm dependency for rendering otpauth:// URIs

Closes TASK-402

* fix: address codex review — separate QR rendering from setup, use clipboard util

- Separate QR code rendering from TOTP setup API call so a QR failure
  doesn't abort setup when manual entry is still available
- Use existing copyToClipboard utility with legacy fallback instead of
  raw navigator.clipboard.writeText
2026-04-14 09:04:37 -04:00
xarmian 56adba4b58 feat: add invitation management panel for admin console (#107)
* feat: add invitation management panel for admin console

Platform-wide view of all pending invitations with search, resend, and
revoke. Resend creates a fresh invitation code and sends the email.
New admin endpoints: GET/POST/DELETE for /admin/invitations.

* fix: check email opt-out on resend, abort on stale delete, reload list

Respect unsubscribe preferences before resending invitation emails.
Abort resend if the old invitation was already accepted/revoked
concurrently. Reload the full invitations list after resend since the
row ID changes.
2026-04-13 23:02:19 -04:00
xarmian 86451174ad feat: add user detail panel with workspace memberships (#106)
* feat: add user detail panel with workspace memberships

New GET /api/v1/admin/users/{id}/workspaces endpoint returning workspace
name, slug, role, and join date. Frontend loads memberships when a user
row is expanded and displays them as a linked list with role badges.

* fix: scope workspace fetch error/loading to active selection

Gate both the catch and finally blocks with a selectedId check so stale
requests from previously selected users don't wipe workspace data or
clear the loading indicator for the current selection.
2026-04-13 22:36:14 -04:00
xarmian b3af1acd07 feat: add last active tracking for users (#105)
* feat: add last active tracking for users

Track when users were last active via a throttled update (once per 5
minutes) in the auth middleware. Adds last_active_at column, displays
relative time in admin user list with full timestamp on hover.

* fix: bound last-active goroutine with 3s context timeout

Use a short-lived context for the background TouchUserActivity write
so it gets cancelled under DB pressure, preventing goroutine/connection
buildup from unbounded background work.
2026-04-13 22:19:41 -04:00
xarmian d968b551b7 feat: add account disable/deactivation (#104)
* feat: add account disable/deactivation for admin users

Allow admins to soft-disable user accounts without deleting data.
Disabled users get a 403 on all authenticated requests, their sessions
are invalidated on disable, and they show as visually dimmed with a
red "disabled" badge in the admin console. Includes migration for
disabled_at column, auth middleware check, disable/enable endpoints
with audit logging, and frontend toggle with confirmation dialog.

* refactor: auto-discover migrations from embedded filesystem

Replace hardcoded migration lists with fs.ReadDir on the embedded FS
directories. New migrations are now picked up automatically by filename
sort order — no need to manually register them in store.go.

* fix: block disabled users at login and capture IDs before async calls

Reject disabled accounts in the login handler before session creation,
not just in RequireAuth middleware (which exempts auth routes). Also
capture selectedId into a local const in all async admin panel functions
to prevent stale updates if the selection changes during a request.

* fix: enforce disabled check in OAuth and password reset flows, always invalidate sessions

Block disabled users in all session-minting paths (OAuth login, password
reset) not just password login. Also remove early return for
already-disabled users in the disable endpoint so session invalidation
always runs, handling retry after partial failure.
2026-04-13 21:56:40 -04:00
xarmian 79d7d26a00 feat: add admin password reset for other users (#103)
* feat: add admin password reset for other users

New POST /api/v1/admin/users/{id}/reset-password endpoint. When email is
configured, sends a password reset link. Otherwise generates a temporary
password and invalidates existing sessions. Includes audit logging via
new password_reset_by_admin action and frontend UI with confirmation.

* fix: treat session revocation and email send as hard failures

Make session invalidation failure abort the reset instead of silently
continuing, and send the reset email synchronously so delivery failures
are surfaced to the admin caller.
2026-04-13 20:59:35 -04:00
xarmian f97ab766f5 feat: add admin role management (promote/demote users) (#102)
* feat: add admin role management (promote/demote users)

Allow admins to change user roles between admin and member from the
admin console. Includes safety guards to prevent self-demotion and
demoting the last admin, with full audit logging.

* fix: make last-admin demotion guard atomic

Move the admin count check into the SQL UPDATE itself so two concurrent
demotion requests cannot both observe >1 admin and proceed. The
conditional UPDATE only demotes when at least one other admin exists,
eliminating the TOCTOU race.
2026-04-13 20:12:16 -04:00
xarmian f276745478 fix: sidebar collection counts ignore terminal status settings (#100)
When all items in a collection had terminal statuses (e.g. all bugs
"fixed"), the sidebar showed the total item count instead of 0.

Root cause: ActiveItemCount used `json:"omitempty"`, so a zero value
was omitted from the API response. The sidebar fallback logic then
displayed item_count (total) instead. Additionally, ListCollections
used a hardcoded global terminal status list instead of respecting
each collection's configured terminal_options.

- Remove omitempty from ItemCount/ActiveItemCount so 0 serializes
- Compute active counts per-collection using schema terminal_options
- Show count of 0 in sidebar when collection has items but all are done
2026-04-13 16:47:44 -04:00
xarmian 7ca0463e70 feat: browser-based CLI authentication flow (#97)
Replace the email/password terminal prompt in `pad auth login` with a
browser-based auth flow. The CLI creates a pending session, prints a URL
the user opens in their browser (works for localhost, remote VPS, or
Pad Cloud), and polls until the session is approved.

- Add CLI auth session endpoints (create, poll, approve)
- Add browser approval page at /auth/cli/{code}
- Rewrite `pad auth login` to use browser flow by default
- Keep `pad auth login --interactive` as email/password fallback
- Add login page redirect param support for post-login bounce-back
- Add SQLite and PostgreSQL migrations for cli_auth_sessions table

Closes PLAN-539, IDEA-404
2026-04-13 10:11:16 -04:00
xarmian 1ba9c91992 feat: email unsubscribe for non-transactional emails (#96)
* feat: email unsubscribe for non-transactional emails

Add CAN-SPAM compliant unsubscribe support:

- New email_optouts table (by email address, not user ID) so
  uninvited recipients can opt out without an account
- HMAC-signed unsubscribe tokens (derived from Maileroo API key)
  so links work without authentication
- GET /api/v1/unsubscribe endpoint with simple HTML confirmation page
- Invitation emails now include unsubscribe footer link
- Welcome emails accept unsubscribe URL parameter
- Before sending invitation emails, check opt-out table and silently
  skip opted-out addresses (prevents invite spam)
- Password reset emails are exempt (transactional, user-initiated)

Fixes BUG-256.

* fix: hide "Copy invite link" when code is unrecoverable

For hashed invitations the plaintext code can't be recovered, so the
button was copying a broken URL. Now shows "Sent via email" label
instead. Only shows the copy button when join_url or code is available.

Fixes BUG-255.
2026-04-13 09:14:04 -04:00
xarmian ac24fb742c fix: breadcrumbs show parent item path for child items (#94)
When viewing a child item (e.g. TASK-101 under PLAN-10), the breadcrumb
now shows "Home / Plans / PLAN-10 / TASK-101" instead of the flat
"Home / Tasks / TASK-101".

- Add parent_slug and parent_collection_slug fields to Go Item model
- Populate them in both single-item and bulk enrichment paths
- Add corresponding TypeScript types
- Update breadcrumb nav to show parent collection and parent item
  when the item has a parent, falling back to the item's own collection

Fixes BUG-516.
2026-04-12 23:53:58 -04:00
xarmian 1e464ffdac fix: apostrophe in slugs, split auto-close, and move navigation (#92)
- Strip apostrophes in slugify() so "Dave's Workspace" becomes
  "daves-workspace" instead of "dave-s-workspace" (BUG-517)
- Use replaceState when navigating after item move to avoid polluting
  browser history (BUG-538)
- Don't auto-close items when split children are done — splitting work
  out doesn't mean the original is complete (BUG-401)
2026-04-12 23:31:13 -04:00
xarmian b2b4feecb9 feat: console navigation, PostgreSQL CI, and operational improvements
- Route root (/) to /console for centralized workspace management
- Update TopBar user dropdown with console nav links (workspaces, settings, billing, admin)
- Move account settings (profile, password, tokens) from workspace settings to /console/settings
- Enhance admin page with email configuration UI and CSRF-protected writes
- Add PostgreSQL CI job to GitHub Actions with race detector on main
- Add `make test-pg` for local PostgreSQL testing via docker-compose
- Expand health/ready endpoint with DB connection pool stats
- Increase item number retry limit for high-concurrency environments
- Add concurrent store benchmarks and FTS search quality tests
- Add AGENTS.md for multi-agent development guidance
2026-04-13 01:29:15 +00:00
xarmian b7808f12a1 fix: address Codex review findings for PR #90 (iteration 2)
Update admin frontend to handle new paginated user list response shape
({ users, total } instead of bare array). Add legacy pad_session cookie
fallback to SessionAuth middleware matching validateSessionCookie. Exempt
/api/v1/plan-limits from RequireAuth so billing page can read limits
without authentication.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-13 01:27:51 +00:00
xarmian 460d213526 fix: address review findings for PR #90 (iteration 1)
Exempt new sidecar endpoints (/admin/stripe-customer-id, /admin/user-by-customer)
from RequireAuth and CSRF middleware. Fix OAuth unlink lockout guard that never
triggered because PasswordHash is always non-empty. Return total count from
admin user list for pagination support.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-13 01:11:04 +00:00
xarmian 93b08b3e1b fix: allow bootstrap in cloud mode when no users exist yet 2026-04-13 01:03:12 +00:00
xarmian 92580905bb feat: cloud hardening and security follow-ups (PLAN-503)
Address 11 issues identified during the PLAN-427 security review:

Critical/High:
- Stripe customer-to-user mapping with indexed lookup (TASK-505)
- OAuth provider linking with explicit consent model (TASK-504)
- CSRF tokens on admin console mutations (TASK-506)
- Rate limiting on cloud admin and OAuth endpoints (TASK-507)

Medium:
- __Host- cookie prefix for subdomain protection (TASK-510)
- Billing portal verifies customer ownership server-side (TASK-515)
- Transactional account deletion with rollback (TASK-509)
- Streaming data export with 60s timeout (TASK-508)
- Migration registration for new columns (TASK-514)

Low:
- Billing page fetches actual plan limits from API (TASK-511)
- Admin user search/filter pushed into SQL with pagination (TASK-512)
2026-04-13 01:03:12 +00:00
xarmian e6f123a4c3 fix: address Codex review findings for PR #89 (iteration 2)
- Exempt /admin/plan from RequireAuth and CSRF middleware so the
  pad-cloud sidecar can call it with cloud_secret body auth
- Add X-CSRF-Token header to admin console PATCH requests
- Send plan_overrides as a JSON string (not parsed object) to match
  backend *string decoder expectation
- Restrict confirm-only account deletion to cloud mode to prevent
  password users from bypassing re-auth

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-13 00:54:21 +00:00
xarmian b2ec0a4f55 fix: address review findings for PR #89 (iteration 1)
Fix admin limits endpoint returning wrong defaults for pro plan, correct
swapped billing page usage numbers, validate expires_at format in plan
endpoint, and handle errors properly in admin stats endpoint.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-13 00:24:36 +00:00
xarmian d0518216c5 feat: add cloud infrastructure for hosted Pad (PLAN-427)
Add the foundation for running Pad as a hosted service at app.getpad.dev.
Same binary in cloud mode with a thin sidecar for OAuth and Stripe.

Cloud mode (PAD_CLOUD=true):
- PAD_CLOUD flag with cloud secret for sidecar communication
- Account-level billing: plan field on users, CheckLimit enforcement
- Free/Pro tiers with configurable limits stored in platform_settings
- Three-tier limit resolution: user overrides → DB defaults → hardcoded fallback
- Plan enforcement on workspace, item, member, webhook, and token creation

Authentication & security:
- OAuth login endpoint (POST /api/v1/auth/oauth-login) with cloud secret gate
- Verified email requirement for OAuth, 2FA bypass protection
- Cloud secret rotation support (comma-separated keys)
- TOTP secret encryption at rest (AES-256-GCM via PAD_ENCRYPTION_KEY)
- Rate limiting on OAuth login endpoint
- Bootstrap disabled in cloud mode
- Password max length enforcement (128 chars)
- Config file written with 0600 permissions

Admin & billing:
- Admin user management API (list, detail, update plan/overrides)
- Configurable plan limits API (GET/PATCH /api/v1/admin/limits)
- Platform stats endpoint
- Admin plan endpoint for sidecar to set user plans
- GDPR: account deletion and data export endpoints

Console UI (cloud mode only):
- /console — workspace list with owned/shared sections
- /console/new — create workspace wizard with slug preview
- /console/settings — profile, password, API tokens
- /console/billing — plan status, upgrade/manage links
- /console/admin — user management, plan overrides, limits editor
- OAuth buttons (GitHub/Google) on login page in cloud mode

Auto-create default workspace on signup in cloud mode.
Migration 035: plan, plan_expires_at, stripe_customer_id, plan_overrides on users.
2026-04-12 17:43:40 +00:00
xarmian 94d35509a4 feat: share links with hardened security, anonymous access, and analytics (#88)
* feat: share links with hashed tokens and /s/{token} route

Add share_links and share_link_views tables with CRUD API and
anonymous resolution route (TASK-421).

Data model:
- share_links: token_hash (SHA-256), target_type/id, permission,
  password_hash, expires_at, max_views, require_auth, view tracking
- share_link_views: per-view records with fingerprint/user tracking

Token security:
- 192-bit entropy (crypto/rand), URL-safe base64 encoding
- SHA-256 hashed at rest, raw token returned only once on creation
- Generic 404 for invalid tokens (no info leakage)
- /api/v1/s/ exempt from auth middleware for anonymous access

API endpoints:
- POST /items/{slug}/share-links — create item share link
- POST /collections/{coll}/share-links — create collection share link
- GET /items/{slug}/share-links — list share links for item
- GET /collections/{coll}/share-links — list for collection
- DELETE /share-links/{id} — revoke share link
- GET /s/{token} — resolve share link, return shared content

D8: Anonymous users are ALWAYS read-only. View count and unique
viewers tracked on each resolution.

* feat: anonymous share page + share link management UI

Add minimal-chrome share link viewer page and share link CRUD in
the share dialog (TASK-422 + TASK-425).

Share page (/s/{token}):
- New SvelteKit route at /s/[token] for anonymous viewing
- Renders item (title, fields, markdown content) or collection
  (name, item list) with no app chrome (no sidebar/topbar)
- Handles require_auth links with "Sign in to view" prompt
- Root layout bypasses auth checks for /s/ routes
- "Powered by Pad" footer

Share dialog updates:
- "Share links" section below existing grants
- Create/list/revoke share links for items and collections
- Copy-to-clipboard for share URLs
- Newly created links highlighted with "only shown once" notice
- View count and auth-required badges

API client:
- ShareLink type added
- shareLinks.* methods for CRUD
- share.get(token) for anonymous resolution

* feat: share link constraints + view analytics

Add password protection, expiry, max views, and view history
endpoints for share links (TASK-423 + TASK-424).

Constraints (TASK-423):
- CreateShareLink accepts ShareLinkOptions: password, expires_at,
  max_views, require_auth, restrict_to_email
- Password hashed with bcrypt, verified on /s/{token} resolution
- Password-protected links return {require_password: true} prompt
- Expiry and max_views already validated by ValidateShareLink

Analytics (TASK-424):
- GET /share-links/{id}/views returns view history with fingerprint,
  user ID, and timestamp
- Response includes total_views, unique_viewers, last_viewed_at
- View history stored per-view in share_link_views table

* fix: harden share links — XSS, access control, data leakage, and UX gaps

- Sanitize rendered markdown with DOMPurify before {@html} injection (XSS)
- Force require_auth=true when restrict_to_email is set (access bypass)
- Reject malformed non-empty JSON bodies with 400 instead of failing open
- Return public DTOs on share endpoints to prevent leaking internal IDs,
  creator info, assignees, schemas, and other sensitive fields
- Enforce max_views atomically via conditional UPDATE to prevent races
- Fix collection share rendering: read items from top-level response key
  and map ref/status fields correctly
- Add password prompt UI and X-Share-Password header support so
  password-protected links can actually be unlocked by the frontend

* fix: follow-up hardening for share links

- Sanitize catch fallback in rendered markdown (XSS edge case if marked throws)
- Remove query-string password fallback; accept only X-Share-Password header
  to avoid leaking passwords in logs, browser history, and referrers
- Return 500 on ListItems DB failure instead of swallowing as empty collection
- Normalize restrict_to_email with ToLower/TrimSpace on create and compare
- Fix malformed JSON check for chunked bodies (ContentLength == -1)
  by checking for io.EOF instead of ContentLength > 0
- Remove internal share_link.id from public DTO responses
- Use clientIP(r) helper for consistent fingerprinting instead of raw
  X-Forwarded-For which is spoofable and includes port in RemoteAddr
- Distinguish DB errors from not-found in share link delete handler

* fix: final hardening pass for share links

- Move auth/email gate before password check to prevent unauthenticated
  callers from probing passwords and burning bcrypt CPU
- Wrap view recording (counter increment, unique-viewer accounting, view
  insert) in a single transaction so a failed insert rolls back the
  consumed view count instead of silently losing it
- Add X-Share-Password to CORS AllowedHeaders so cross-origin
  deployments can send the custom header without preflight rejection
- Validate expires_at (RFC3339) and max_views (> 0) on share link
  creation; return 400 for invalid constraints instead of creating
  immediately-unusable links
- Cap view-history endpoint limit to 1000 to prevent unbounded queries
2026-04-11 16:40:18 -04:00
xarmian c6d19837c8 feat: collection & item grants, guest access, share dialog (PLAN-407 Phase 3) (#87)
* feat: collection and item grants tables + permission resolution

Add grant tables, CRUD operations, and permission resolution for
guest access and member overrides (TASK-417).

Data model:
- collection_grants table (id, collection_id, workspace_id, user_id,
  permission, granted_by) with CASCADE on collection/user delete
- item_grants table (same structure, references items)
- Indexes for user/collection/item lookups

Store methods:
- Create/Get/List/Delete for both collection and item grants
- ListUserGrants: all grants for a user across a workspace
- RevokeAllUserGrants: bulk delete for member removal
- ResolveUserPermission: full 5-step resolution per DOC-406
  (owner → item grant → collection grant → membership → deny)

API endpoints:
- GET/POST/DELETE /collections/{coll}/grants — collection grant CRUD
- GET/POST/DELETE /items/{slug}/grants — item grant CRUD
- GET /users/{userID}/grants — all grants for a user in workspace

All grant endpoints are owner-only for creation/deletion.

* feat: grant revocation + member removal with grant choice

Update member removal to support D4: owner chooses whether to revoke
all grants when removing a member (TASK-489).

- DELETE /members/{userID}?revoke_grants=true → remove membership AND
  all collection/item grants (full removal)
- DELETE /members/{userID} (or revoke_grants=false) → remove membership
  but keep grants (user becomes a guest with existing access)
- Audit log records whether grants were revoked
- CASCADE DELETE on collection/item deletion already handles cleanup
  (via ON DELETE CASCADE in the grants migration)

* feat: share dialog UI for items and collections + grant types

Add a share dialog component for managing grants on items and
collections, plus TypeScript types and API client methods (TASK-419).

Frontend:
- ShareDialog.svelte: reusable modal for listing/creating/revoking
  grants, with email input, permission select, and revoke buttons
- Item detail page: "Share" button in meta-actions (owner-only)
- Collection page: "Share" button in header actions (owner-only)

TypeScript:
- CollectionGrant and ItemGrant types added
- API client: grants.listCollectionGrants, createCollectionGrant,
  deleteCollectionGrant, listItemGrants, createItemGrant,
  deleteItemGrant, listUserGrants

Guest home screen (TASK-418) deferred — requires layout-level guest
detection which will be implemented when guest routing is built.

* feat: guest access — grants-based workspace access for non-members

Allow authenticated users with grants (but no workspace membership)
to access workspaces as guests (TASK-418).

Backend:
- UserHasGrantsInWorkspace: checks if user has any collection/item
  grants in a workspace
- GuestVisibleCollectionIDs: returns collections visible to a guest
  via collection grants + collections containing granted items
- RequireWorkspaceAccess: after member-nil check, falls through to
  grant check; sets role to "guest" if grants exist
- VisibleCollectionIDs: non-members now checked for guest grants
  instead of returning empty
- GetUserWorkspaces: includes guest workspaces (is_guest flag)
- GetWorkspacesBySlugForUser: JOINs on grants tables so workspaces
  resolve for guests
- roleLevel: "guest" = 0 (below viewer, blocks role-gated actions)

Frontend:
- Workspace.is_guest field in TypeScript type
- Sidebar: hides Dashboard, Roles, Activity, Settings, and "New
  collection" button for guests; shows "Shared with you" header

* feat: wiki-link rendering with locked icon for hidden items

Update wiki-link rendering to show a 🔒 locked icon when the linked
item is in a collection the user can't see (TASK-420).

- renderMarkdown accepts optional visibleCollectionSlugs parameter
- Items in hidden collections render as "🔒 Title" with tooltip
- Unresolved links still render as broken (no change)
- Username param added to renderMarkdown for correct URL construction
- TimelineCommentCard and CommentThread accept username prop

* fix: harden grant security — 9 findings from Codex review

- Item grants no longer leak collection-wide read access; guests with
  item-level grants see only their granted items, not the full collection
  (GuestVisibleResources two-level filter + ItemIDs in ListItems SQL).
- Edit grants are now enforced: mutating handlers (create/update/delete
  items, comments, reactions, links, versions) resolve grant-based
  permissions for guests via requireEditPermission + ResolveUserPermission.
- Grant list endpoints restricted to owners (collection/item grants) or
  owner-or-self (user grants) to prevent metadata/email enumeration.
- Guests blocked from listing workspace members; invitation details
  restricted to owners only.
- Grant deletion scoped to workspace_id to prevent cross-workspace
  deletion by guessing grant IDs.
- Member removal now revokes grants by default (opt-out with
  ?revoke_grants=false) and propagates revocation errors instead of
  silently discarding them.
- Guest workspace listing properly propagates DB errors instead of
  swallowing them.
- PostgreSQL subquery alias added to UserHasGrantsInWorkspace to fix
  silent guest-access failures on Postgres deployments.

* fix: harden item-level grant isolation — 7 findings from Codex re-review

- /changes endpoint now filters by item-level grants so guests with one
  item grant no longer receive updates for every item in that collection.
- Search results filtered by item-level grants (new ItemIDs field in
  SearchParams) so guests can't discover other items via search.
- Relationship/summary endpoints (item links, children, progress,
  activity, dashboard) all apply item-level visibility checks via
  isItemVisibleToGuest(), preventing metadata leakage through related
  item titles, statuses, and counts.
- Grants now work as member overrides: a viewer with an edit grant can
  edit the granted item (requireEditPermission falls back to
  ResolveUserPermission for members below editor role).
- handleMoveItem now requires edit permission on the target collection,
  not just visibility, preventing guests from moving items into
  view-only collections.
- Member removal + grant revocation is now atomic via
  RemoveWorkspaceMemberAndRevokeGrants() which wraps both operations
  in a single database transaction.
- Guest-access DB errors in middleware now return 500 with slog.Error
  instead of being silently collapsed into a 403 forbidden response.

* fix: close remaining grant isolation gaps — 10 findings from Codex round 3

- Workspace token endpoints (create/list/delete) now require owner role,
  preventing guests from enumerating or revoking API tokens.
- Legacy document endpoints (list, get, context, bulk-read, backlinks,
  links) now require at least viewer role, blocking guests entirely
  since documents are outside the grants model.
- Global search no longer relies on workspaceRole() (which is unset
  outside RequireWorkspaceAccess); detects guests via IsWorkspaceMember
  and applies item-level filtering. Multi-workspace search now uses
  GuestVisibleResources for guest workspaces.
- SSE event filtering now checks item IDs for guests with item-level
  grants, not just collection slugs, preventing live event leaks.
- Role board passes ItemIDs through RoleBoardParams so guests only
  see items they have grants on, not the entire collection.
- VisibleCollectionIDs for members with "specific" collection access
  now merges direct grants (collection + item grants), so grant
  overrides work for restricted members.
- Plans-progress endpoint filters plan items and children by item-level
  grants for guests, preventing one plan grant from exposing all plans.
- Webhook listing now requires owner role since URLs may contain secrets.
- Agent role item counts use item-level filtering for guests.
- Link deletion checks item-level visibility on both endpoints, not
  just collection-level.

* fix: close member grant escalation and remaining edge cases — round 4

- Item grants for restricted members no longer escalate to collection-
  wide visibility. VisibleCollectionIDs now merges only direct collection
  grants (not item-derived collections) into member access. Item-level
  filtering (guestResourceFilter, isItemVisibleToGuest, requireItemVisible)
  now applies to both guests AND restricted members with item grants,
  closing the gap where a member with specific collection access plus
  one item grant could see/edit all items in that collection.
- Guests blocked from workspace-level activity feed (/activity) which
  exposed audit events (member invites, role changes) with operational
  metadata. Requires at least viewer role.
- Global search no longer returns zero results for item-only guests.
  Store.Search early-return now checks both CollectionIDs and ItemIDs
  are empty before short-circuiting, so item-level grants work in
  global (multi-workspace) search.
- UserHasGrantsInWorkspace now excludes item grants on soft-deleted
  items, preventing phantom guest access to a workspace shell with
  no visible content when the only granted item is archived.

* fix: prevent grant filter from overriding member access, close SSE/dashboard/collection leaks — round 5

- guestResourceFilter now returns nil/nil for members with "all"
  collection access, preventing item grants from accidentally replacing
  their full visibility. Only guests and members with "specific"
  collection access get item-level filtering applied. This fixes a
  regression where a normal member receiving one item grant would lose
  access to all other items.
- requireItemVisible uses guestResourceFilter (with the same scoping)
  instead of raw GuestVisibleResources, so the member-access check is
  consistent throughout all code paths.
- SSE event filtering now denies collection-less events (workspace
  updates, legacy document events) for guests, preventing metadata
  leakage through realtime event payloads.
- Dashboard recent activity filters out workspace-level entries (no
  DocumentID) for guests, preventing audit metadata leakage.
- All grant visibility queries (UserHasGrantsInWorkspace,
  GuestVisibleCollectionIDs, GuestVisibleResources) now join the
  collections table and require deleted_at IS NULL, so grants on
  soft-deleted collections no longer provide phantom access.

* fix: make item grants additive for restricted members, close write/search/SSE gaps — round 6

- guestResourceFilter now merges member_collection_access + system
  collections + collection grants into fullCollIDs for restricted members,
  making item grants additive to existing access. Previously, item grants
  replaced the member's normal collections, causing members with one item
  grant to lose all their other collection visibility.
- Added ListSystemCollectionIDs store method for system collection lookup.
- Search (both global and workspace-scoped) now applies item-level
  filtering for restricted members with item grants, not just guests.
  Previously VisibleCollectionIDs included item-granted collections as
  full-access, leaking all items in those collections via search.
- SSE event filtering now builds item-level filters for restricted
  members with item grants (previously only for non-members/guests),
  and merges member collections into the full-access set.
- Role board reorder now uses requireItemVisible + requireEditPermission
  per item instead of collection-only visibility check, preventing
  restricted editors from reordering items in item-granted collections.
- View create/update/delete now check requireEditPermission on the
  collection (via requireViewEditable), not just collection visibility.
- GetUserWorkspaces guest query now joins collections/items tables to
  exclude grants on soft-deleted resources, matching the behavior of
  UserHasGrantsInWorkspace.

* fix: block guests from legacy doc versions/activity, fix ListItems early return, SSE fail-closed — round 7

- Legacy document version handlers (handleListVersions, handleGetVersion)
  and document activity handler (handleListDocumentActivity) now require
  at least viewer role, blocking guests from reading version history and
  activity for unrelated legacy documents.
- ListItems early return now checks both CollectionIDs and ItemIDs are
  empty before short-circuiting, matching the fix already applied to
  Search. This fixes item-only guests seeing zero results from /items,
  dashboard, role board, and agent-role counts.
- SSE item-grant filtering now fails closed on GuestVisibleResources
  errors: installs empty item/collection filter sets instead of falling
  through with nil (which would pass all events through).
- Role board reorder removed top-level requireMinRole("editor") so the
  per-item grant-aware requireEditPermission checks can run for guests
  and viewers with edit grants, consistent with other mutating handlers.
2026-04-11 14:46:24 -04:00
xarmian 873f834454 fix: close remaining visibility bypass paths (round 5)
HIGH:
- SSE endpoint now verifies workspace access for legacy API tokens by
  checking tokenWorkspaceID matches the requested workspace. Also uses
  resolveWorkspace for user-aware resolution instead of raw slug lookup.

MEDIUM:
- ParentLinkID no longer set on list responses when the parent is in a
  hidden collection — previously leaked the hidden parent's UUID even
  though title/ref were filtered.
- Dashboard role breakdown (ByRole) now recomputed from visible items
  when user has restricted access, preventing hidden collection workload
  and assignee leaks.

LOW:
- HasChildren computed from visible grandchildren only when visibility
  is restricted, instead of querying all descendants unfiltered.
- handleGetItemLinks and handleListAgentRoles now fail closed (500) on
  visibleCollectionIDs errors instead of returning unfiltered data.
2026-04-11 03:02:14 +00:00
xarmian 2310d15299 fix: close remaining visibility bypass paths (round 4)
HIGH:
- Saved view endpoints (list, create, update, delete) now check
  collection visibility. Added requireViewVisible helper that verifies
  workspace ownership and collection access for update/delete by view ID.
- Dashboard plan progress computed from visible children only instead of
  using GetItemProgress which counts all children. Suggested Next also
  filters out tasks from hidden collections.
- Global search initializes allVisibleCollIDs as non-nil empty slice so
  zero-visible-collection case correctly returns no results instead of
  searching unfiltered.

MEDIUM:
- /plans-progress recomputes progress from visible children when user
  has restricted access, matching the per-item progress approach.
- Agent role item_count recomputed from visible items in handler when
  visibility is restricted, preventing hidden collection item count leaks.
- Parent filter resolution (?parent=, ?plan=) now checks resolved
  parent's collection visibility, returning same not-found error for
  hidden parents to prevent existence probing. Added request parameter
  to resolveParentFilter.
- UUID parent that doesn't exist now returns 400 immediately instead of
  setting parentValue and failing later with FK error after item insert.

LOW:
- Restricted progress computation uses per-collection schemas via
  IsTerminalStatus instead of IsTerminalStatusDefault, matching the
  unrestricted SQL path's behavior for custom terminal statuses.
2026-04-11 02:49:13 +00:00
xarmian 1c37493b45 fix: close remaining visibility bypass paths (round 3)
HIGH:
- Single-item enrichment (derived_closure, parent_title, parent_ref) now
  filters related items by collection visibility. enrichItemForResponse
  and deriveItemClosure accept optional visibleIDs to exclude links to
  hidden-collection items.
- Dashboard blocker attention skips blockers from hidden collections
  instead of leaking their titles and statuses.
- Link deletion now looks up the link, verifies workspace ownership, and
  checks that both linked items are in visible collections before
  allowing the delete. Added GetItemLinkByID store method.

MEDIUM:
- Workspace activity filters now drop rows with empty CollectionSlug
  that have an item reference (unresolved hidden items) instead of
  passing them through.
- UUID parent assignment validates parent belongs to the same workspace
  before creating cross-workspace links.

LOW:
- GET /members/{userID}/collection-access now requires owner role or
  matching user ID, preventing viewers from querying other members'
  hidden collection grants.
2026-04-11 02:35:15 +00:00
xarmian 973887d5dd fix: comprehensive collection visibility enforcement
Close all identified bypass paths in the collection visibility system:

HIGH:
- Add requireItemVisible check to all 15+ item-by-slug handlers (get,
  update, delete, restore, move, children, progress, activity, versions,
  timeline, comments, links)
- Filter incremental sync (GET /changes) by visible collections with
  proper error handling for deleted item lookups
- Fix search to fail closed on visibility errors instead of removing
  the collection filter; apply per-workspace filtering in multi-workspace
  search path
- Empty CollectionIDs (non-nil but len 0) now returns zero results in
  ListItems and Search instead of skipping the filter
- Filter returned item links by linked item visibility; require target
  item visibility before creating links
- Block moving items into hidden collections
- Add visibility checks to comment-by-ID routes (delete, reply,
  add/remove reaction)

MEDIUM:
- SSE events for replies and reactions now include collection slug so
  visibility filtering can scope them; fail closed on visibility error
- Parent/plan resolution in create/update checks resolved parent is in
  a visible collection
- Progress endpoints compute from visible children only when user has
  restricted access
- Role board reorder checks item visibility before allowing sort changes
- Parent enrichment accepts optional visibility filter to hide parents
  from hidden collections
- Add IsSystem: true to Conventions and Playbooks in defaults.go

LOW:
- Child listing handles visibility lookup errors instead of failing open
- GetDeletedItemsWithCollection returns proper errors instead of
  swallowing them
- SetMemberCollectionAccess wrapped in transaction with workspace
  validation for collection IDs
2026-04-11 02:18:36 +00:00
xarmian 0587deba41 feat: UI for managing member collection visibility
Add API endpoints and settings UI for managing per-member collection
access (TASK-416).

Backend:
- GET /members/{userID}/collection-access — returns mode + granted IDs
- PUT /members/{userID}/collection-access — sets mode + collection IDs
  (owner-only)

Frontend:
- API client: getMemberCollectionAccess, setMemberCollectionAccess
- Settings Members tab: "Manage access" button per member (owner-only)
- Expandable inline panel with all/specific toggle
- Collection checkbox list: non-system collections toggleable, system
  collections always checked + disabled with "system" tag
- Save/cancel with optimistic update
2026-04-11 01:35:51 +00:00
xarmian 3454930099 test: Phase 2 permission resolution test suite
Add 9 tests covering collection-level visibility, system collection
exemptions, and the VisibleCollectionIDs resolution logic (TASK-488
Phase 2 increment).

Tests:
- VisibleCollectionIDs: all access returns nil, specific access
  returns granted + system collections, non-member gets empty list
- SystemCollectionsAlwaysVisible: conventions visible even when not
  explicitly granted
- SetMemberCollectionAccess: replace grants, switch back to all
- ListItems filtered by CollectionIDs
- Default collection_access is "all" (D7)
- IsSystem flag on collections
2026-04-11 01:27:36 +00:00
xarmian a0f2000968 feat: database indexes for permission tables
Add indexes to support permission-filtered queries at scale (TASK-486).

- idx_mca_collection: reverse lookup on member_collection_access for
  cascade cleanup when collections are deleted
- idx_collections_system: partial index on (workspace_id, is_system)
  for fast system collection lookups in VisibleCollectionIDs
- idx_wm_user: index on workspace_members(user_id) for user deletion
  cascade and cross-workspace membership queries
2026-04-11 01:25:34 +00:00