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.
71 lines
3.2 KiB
Go
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[]))`
|
|
}
|