PhoenixKitProjects.Members (PhoenixKitProjects v0.21.1)

Copy Markdown View Source

Per-project membership — the hub's native people layer (P2a).

Members are core users with a project role (owner > manager > member > viewer). The project creator becomes the first owner (Projects.create_project/2 with actor_uuid). Role semantics live in PhoenixKitProjects.Authz — this module owns the rows and the guards:

  • Last-owner guard — the final owner can be neither removed nor demoted ({:error, :last_owner}); a project must always have an accountable owner (the permission-hardening precedent).
  • Membership mutations log activity WITH target_uuid = the affected user, so core's activity→notification bridge delivers "you were added to a project" through the user's channels automatically.

Summary

Functions

Every non-template project a user can reach, as {project, role} — their own memberships PLUS anything their teams, departments, or site roles grant, with the strongest role per project.

Adds a member. Options: :role (default "member"), :actor_uuid. Adding an existing member updates their role through the same guards.

Changes a member's role. Demoting the last owner is refused ({:error, :last_owner}).

Ensures the project creator holds the owner seat — called by Projects.create_project/2 when it knows the actor. Idempotent; best-effort (a failure logs, never aborts the create).

The member row for a user on a project, or nil.

The before_user_delete/1 lifecycle work (core calls the module hook BEFORE the user row deletes, while memberships still exist — the cascade would otherwise orphan sole-owner projects silently, the final panel's ZAI #5)

All members of a project, owners first, user preloaded.

Every project the user is a member of, as {project, role} pairs — templates excluded, newest membership first. The member-surface listing (/dashboard/projects); fail-closed to [] on any read error.

Removes a member. The last owner cannot be removed ({:error, :last_owner}).

The user's role on a project as an atom, or nil when not a member. Fail-closed.

Functions

accessible_projects(user_uuid)

@spec accessible_projects(binary()) :: [{map(), String.t()}]

Every non-template project a user can reach, as {project, role} — their own memberships PLUS anything their teams, departments, or site roles grant, with the strongest role per project.

This is the single scope every listing surface should use. A view that keeps joining phoenix_kit_project_members directly will silently omit group-granted projects — the leak class the quorum flagged first.

Site admins are NOT special-cased here: this answers "what does this person hold", and projects.admin_all is a separate question the caller asks with PhoenixKitProjects.Authz.can?/5.

add_member(project_or_uuid, user_uuid, opts \\ [])

@spec add_member(map() | binary(), binary(), keyword()) ::
  {:ok, PhoenixKitProjects.Schemas.ProjectMember.t()} | {:error, term()}

Adds a member. Options: :role (default "member"), :actor_uuid. Adding an existing member updates their role through the same guards.

change_role(project_or_uuid, member_or_user_uuid, new_role, actor_uuid \\ nil)

@spec change_role(
  map() | binary(),
  binary() | PhoenixKitProjects.Schemas.ProjectMember.t(),
  String.t(),
  binary() | nil
) :: {:ok, PhoenixKitProjects.Schemas.ProjectMember.t()} | {:error, term()}

Changes a member's role. Demoting the last owner is refused ({:error, :last_owner}).

ensure_creator_owner(project_uuid, actor_uuid)

@spec ensure_creator_owner(binary(), binary() | nil) :: :ok

Ensures the project creator holds the owner seat — called by Projects.create_project/2 when it knows the actor. Idempotent; best-effort (a failure logs, never aborts the create).

get_member(project_uuid, user_uuid)

@spec get_member(binary(), binary()) ::
  PhoenixKitProjects.Schemas.ProjectMember.t() | nil

The member row for a user on a project, or nil.

handle_user_deletion(user_uuid)

@spec handle_user_deletion(binary()) :: :ok

The before_user_delete/1 lifecycle work (core calls the module hook BEFORE the user row deletes, while memberships still exist — the cascade would otherwise orphan sole-owner projects silently, the final panel's ZAI #5):

  • sole owner WITH other members → the most senior remaining member (manager > member > viewer, earliest seat wins ties) is promoted to owner (projects.ownership_succeeded);
  • sole owner with NO other members → an orphan-warning activity row (projects.owner_departed) — admin-recoverable, never guessed;
  • every membership departure is logged with the deletion reason.

Best-effort by contract: core logs and continues if this raises.

list_members(project_uuid)

@spec list_members(binary()) :: [PhoenixKitProjects.Schemas.ProjectMember.t()]

All members of a project, owners first, user preloaded.

projects_for_user(user_uuid)

@spec projects_for_user(binary()) :: [{map(), String.t()}]

Every project the user is a member of, as {project, role} pairs — templates excluded, newest membership first. The member-surface listing (/dashboard/projects); fail-closed to [] on any read error.

remove_member(project_or_uuid, user_uuid, opts \\ [])

@spec remove_member(map() | binary(), binary(), keyword()) ::
  {:ok, PhoenixKitProjects.Schemas.ProjectMember.t()} | {:error, term()}

Removes a member. The last owner cannot be removed ({:error, :last_owner}).

role_of(project_or_uuid, user_uuid)

@spec role_of(binary() | map(), binary()) :: atom() | nil

The user's role on a project as an atom, or nil when not a member. Fail-closed.