PhoenixKitProjects.Assignees (PhoenixKitProjects v0.18.0)

Copy Markdown View Source

The single resolver for "whose work is this?" questions against the polymorphic assignment assignee (person OR team OR department).

A person's effective scope is themselves plus one level of inherited membership: the teams they belong to (staff TeamMembership) and the departments those teams sit in plus their own primary department. One level only, by design — transitive roll-ups (team of team, parent departments) don't exist in the staff model and would be guesswork here.

match/2 answers whether an assignment falls in a scope and HOW — :direct or {:team, name} / {:department, name} — so UIs can show provenance ("via Engineering") instead of implying personal ownership. Consumers pick their semantics: inherited = any match; direct-only = :direct matches.

Built for the Overview calendar's assignee filter, but deliberately UI-agnostic: any future per-person surface (widgets, queues, a staff-side consumer via an events contract) should resolve through this module rather than re-deriving membership joins — the panel-review risk was exactly this logic drifting between call sites.

Staff reads are rescued to safe defaults (same convention as Projects.list_assignments_for_user/1) — an assignee filter must never crash the dashboard.

Summary

Types

A resolved person scope: the person plus their one-level memberships, with display names captured for provenance labels.

Functions

How assignment falls inside scope

Builds the effective scope for a staff person: their uuid, their teams (via memberships), and their departments (the teams' departments + their primary department). Names are localized to lang for provenance labels.

The scope for the CURRENT USER (auth uuid → staff person), or nil when they have no staff person. Backs the filter's "Me" shortcut.

Searches people for the assignee picker (core <.search_picker> contract): name/email typeahead, LIMITed at the database (limit+1 probes has_more for the picker's Load more), trashed people excluded. An empty query is browse mode — the first page of everyone, name-sorted — per the workspace picker rule that a picker must offer options before any typing.

Whether the assignment has no assignee at all (person/team/department).

Types

scope()

@type scope() :: %{
  person_uuid: String.t(),
  person_name: String.t(),
  team_names: %{optional(String.t()) => String.t()},
  department_names: %{optional(String.t()) => String.t()}
}

A resolved person scope: the person plus their one-level memberships, with display names captured for provenance labels.

Functions

match(a, scope)

@spec match(PhoenixKitProjects.Schemas.Assignment.t(), scope()) ::
  :direct | {:team, String.t()} | {:department, String.t()} | nil

How assignment falls inside scope:

  • :direct — assigned to the person themselves
  • {:team, name} — assigned to a team the person belongs to
  • {:department, name} — assigned to a department in the person's scope
  • nil — outside the scope

Direct-only consumers accept only :direct; inherited consumers accept any non-nil result (and can show the tuple's name as provenance).

scope_for_person(person_uuid, lang \\ nil)

@spec scope_for_person(String.t(), String.t() | nil) :: scope() | nil

Builds the effective scope for a staff person: their uuid, their teams (via memberships), and their departments (the teams' departments + their primary department). Names are localized to lang for provenance labels.

Returns nil when the person doesn't exist (or the staff read fails).

scope_for_user(user_uuid, lang)

@spec scope_for_user(String.t() | nil, String.t() | nil) :: scope() | nil

The scope for the CURRENT USER (auth uuid → staff person), or nil when they have no staff person. Backs the filter's "Me" shortcut.

search_people(query, limit \\ 8, opts \\ [])

@spec search_people(String.t() | nil, pos_integer(), keyword()) ::
  {[map()], boolean()}

Searches people for the assignee picker (core <.search_picker> contract): name/email typeahead, LIMITed at the database (limit+1 probes has_more for the picker's Load more), trashed people excluded. An empty query is browse mode — the first page of everyone, name-sorted — per the workspace picker rule that a picker must offer options before any typing.

Returns {rows, has_more} where each row is the picker's %{kind:, uuid:, label:, sublabel:, icon:} shape. {[], false} on a failed staff read. opts[:exclude] drops the given person uuids at the database (already-picked chips shouldn't reappear as suggestions) without disturbing the page/has_more math.

Queries the staff Person schema directly (already a hard schema dep via the assignment FKs) because the staff context's list_people/1 has no LIMIT — the whole point here is not loading 1000 people per keystroke.

unassigned?(a)

Whether the assignment has no assignee at all (person/team/department).