Files
paliad/internal/services/visibility.go
m a69fff73e9 feat(t-paliad-124): project filter includes descendant projects
Selecting a Client in the project filter now returns rows attached to
that Client AND every Litigation / Patent / Case below it (and so on
down the tree). Previously the filter was exact-match: picking a Client
hid every item in the subtree, which was the opposite of what users
expect when they pick a parent in a hierarchical picker.

The descendant set comes from paliad.projects.path - every project's
path always contains its own id and every ancestor's id, so any project
whose path includes the filter UUID is either that project or a
descendant. Pattern matches the existing visibility predicate (which
walks the path UPWARD for inheritance); the new helper just inverts the
direction.

Filter sites updated:
  - DeadlineService.ListVisibleForUser     (/deadlines, /events)
  - DeadlineService.SummaryCounts          (deadline summary cards)
  - AppointmentService.ListVisibleForUser  (/appointments, /events)
  - EventService.deadlineBuckets           (/events deadline rail)
  - EventService.appointmentBuckets        (/events appointment rail)

ListForProject (deadline/appointment/checklist/note) is unchanged - it
fetches items for ONE specific project on the project detail page, not
a filter.

Visibility predicate (paliad.can_see_project) untouched - that walks
upward and is a different concern.
2026-05-04 18:57:06 +02:00

71 lines
3.2 KiB
Go

package services
// Application-layer mirror of paliad.can_see_project (defined in migration
// 023). The SQL function is enforced by RLS, but services run with a
// service-role connection that has no auth.uid() — so each query that
// returns project-scoped rows re-applies the same predicate inline.
//
// Predicate shape (matches can_see_project body):
//
// global_admin in paliad.users OR any (direct or ancestor) team membership
//
// We resolve the global-admin shortcut by EXISTS-querying paliad.users on
// the supplied userID rather than asking callers to pass user.GlobalRole as
// a separate parameter. That eliminates an entire bug class (mismatched
// role string, e.g. checking 'admin' when the column stores 'global_admin')
// and means the only argument the predicate needs is the user UUID.
//
// Callers must JOIN paliad.projects under the given alias so the path-walk
// half of the predicate can read alias.path.
import "fmt"
// visibilityPredicate returns the predicate using sqlx named bind variables.
// Caller binds :user_id to the requesting user's UUID.
//
// Uses CAST(... AS uuid[]) — not the `::uuid[]` shorthand — because sqlx
// v1.4.0's named-parameter parser strips one colon from `::uuid[]` while
// compiling `:user_id` placeholders, producing invalid SQL `:uuid[]` that
// Postgres rejects with `syntax error at or near ":"`.
func visibilityPredicate(alias string) string {
return `(EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = :user_id AND u.global_role = 'global_admin'
) OR EXISTS (
SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = :user_id
AND pt.project_id = ANY(CAST(string_to_array(` + alias + `.path, '.') AS uuid[]))
))`
}
// visibilityPredicatePositional returns the predicate with one positional
// placeholder ($userArg) for the user UUID. Use when the surrounding query
// can't take named parameters (mixed positional args, lib/pq array binding,
// etc.). The same $userArg is referenced twice — that is a feature of
// Postgres positional binding, not a bug.
func visibilityPredicatePositional(alias string, userArg int) string {
return fmt.Sprintf(`(EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = $%d AND u.global_role = 'global_admin'
) OR EXISTS (
SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = $%d
AND pt.project_id = ANY(string_to_array(%s.path, '.')::uuid[])
))`, userArg, userArg, alias)
}
// projectDescendantPredicate matches rows whose joined `paliad.projects`
// row (under `alias`) is the project bound to :project_id OR any descendant
// of it. The check inspects the materialised path column on paliad.projects
// — :project_id appears in the path of every descendant, plus the project
// itself (since path always includes self as the last label, t-paliad-018).
//
// Uses CAST(... AS uuid[]) — not the `::uuid[]` shorthand — for the same
// sqlx-named-parameter reason called out on visibilityPredicate above.
//
// Caller must JOIN paliad.projects under the given alias and bind
// :project_id to the selected project's UUID.
func projectDescendantPredicate(alias string) string {
return `:project_id = ANY(CAST(string_to_array(` + alias + `.path, '.') AS uuid[]))`
}