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
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.
@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.
@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}).
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).
@spec get_member(binary(), binary()) :: PhoenixKitProjects.Schemas.ProjectMember.t() | nil
The member row for a user on a project, or nil.
@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.
@spec list_members(binary()) :: [PhoenixKitProjects.Schemas.ProjectMember.t()]
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.
@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}).
The user's role on a project as an atom, or nil when not a member. Fail-closed.