Skills: CRUD for the Skill taxonomy plus person↔skill assignment
(with proficiency level).
Skill CRUD mirrors PhoenixKitStaff.Teams. The assignment functions
(assign_skill/unassign_skill/…) live here for cohesion — note this
deviates from team membership, which lives in PhoenixKitStaff.Staff;
thin delegators are exposed there (Staff.assign_skill/3, etc.) so both
context boundaries reach it.
Summary
Functions
Assigns a skill to a person at zero or more of the skill's levels (a list of
level ids); broadcasts :person_skill_added. Validates the ids against the
skill (must exist; ≤1 unless the skill allows multiple).
Returns a changeset for the given skill.
Total number of skills.
Inserts a skill and broadcasts :skill_created.
Deletes a skill (cascades its assignments) and broadcasts :skill_deleted.
Fetches a skill by uuid, or nil if not found.
Fetches a skill by uuid. Raises if not found.
Lists skills ordered by name. Accepts :preload.
A person's skill assignments, skill preloaded, ordered by skill name.
People assigned a given skill, person + user preloaded. Trashed people are excluded (the assignment row survives a trash; restore brings them back).
Non-trashed people not yet assigned this skill (for the add picker).
Map of skill_uuid => assigned-people count (one query, for the list view).
Skills not yet assigned to a person, ordered by name (for the person-side add picker).
Removes an assignment (by struct or person/skill uuids); broadcasts :person_skill_removed.
Updates a skill and broadcasts :skill_updated.
Replaces an assignment's selected levels (a list of level ids); broadcasts
:person_skill_updated. Validates against the parent skill, under the same
FOR UPDATE skill-row lock as assign_skill/3.
Validates a list of level ids against a skill: every id must exist in the
skill's levels, and there must be ≤1 unless allow_multiple_levels. Returns
the ids cleaned + reordered to the skill's level order on success.
Functions
@spec assign_skill( UUIDv7.t() | String.t(), UUIDv7.t() | String.t(), [String.t()] | String.t() | nil ) :: {:ok, PhoenixKitStaff.Schemas.PersonSkill.t()} | {:error, :skill_not_found | :invalid_levels | :too_many_levels | Ecto.Changeset.t()}
Assigns a skill to a person at zero or more of the skill's levels (a list of
level ids); broadcasts :person_skill_added. Validates the ids against the
skill (must exist; ≤1 unless the skill allows multiple).
Runs in a transaction that FOR UPDATE-locks the skill row before validating,
so it can't race a concurrent update/2 and persist a just-removed level id.
@spec change(PhoenixKitStaff.Schemas.Skill.t(), map()) :: Ecto.Changeset.t(PhoenixKitStaff.Schemas.Skill.t())
Returns a changeset for the given skill.
@spec count() :: non_neg_integer()
Total number of skills.
@spec create(map()) :: {:ok, PhoenixKitStaff.Schemas.Skill.t()} | {:error, Ecto.Changeset.t(PhoenixKitStaff.Schemas.Skill.t())}
Inserts a skill and broadcasts :skill_created.
@spec delete(PhoenixKitStaff.Schemas.Skill.t()) :: {:ok, PhoenixKitStaff.Schemas.Skill.t()} | {:error, Ecto.Changeset.t(PhoenixKitStaff.Schemas.Skill.t())}
Deletes a skill (cascades its assignments) and broadcasts :skill_deleted.
@spec get( UUIDv7.t() | String.t(), keyword() ) :: PhoenixKitStaff.Schemas.Skill.t() | nil
Fetches a skill by uuid, or nil if not found.
@spec get!( UUIDv7.t() | String.t(), keyword() ) :: PhoenixKitStaff.Schemas.Skill.t()
Fetches a skill by uuid. Raises if not found.
@spec list(keyword()) :: [PhoenixKitStaff.Schemas.Skill.t()]
Lists skills ordered by name. Accepts :preload.
@spec list_for_person(UUIDv7.t() | String.t()) :: [ PhoenixKitStaff.Schemas.PersonSkill.t() ]
A person's skill assignments, skill preloaded, ordered by skill name.
@spec list_people_for_skill(UUIDv7.t() | String.t()) :: [ PhoenixKitStaff.Schemas.PersonSkill.t() ]
People assigned a given skill, person + user preloaded. Trashed people are excluded (the assignment row survives a trash; restore brings them back).
@spec people_without_skill(UUIDv7.t() | String.t()) :: [ PhoenixKitStaff.Schemas.Person.t() ]
Non-trashed people not yet assigned this skill (for the add picker).
@spec person_counts() :: %{optional(UUIDv7.t()) => non_neg_integer()}
Map of skill_uuid => assigned-people count (one query, for the list view).
@spec skills_not_assigned_to(UUIDv7.t() | String.t()) :: [ PhoenixKitStaff.Schemas.Skill.t() ]
Skills not yet assigned to a person, ordered by name (for the person-side add picker).
@spec unassign_skill(PhoenixKitStaff.Schemas.PersonSkill.t()) :: {:ok, PhoenixKitStaff.Schemas.PersonSkill.t()} | {:error, Ecto.Changeset.t(PhoenixKitStaff.Schemas.PersonSkill.t())}
Removes an assignment (by struct or person/skill uuids); broadcasts :person_skill_removed.
@spec unassign_skill(UUIDv7.t() | String.t(), UUIDv7.t() | String.t()) :: {:ok, PhoenixKitStaff.Schemas.PersonSkill.t()} | {:error, :not_found} | {:error, Ecto.Changeset.t(PhoenixKitStaff.Schemas.PersonSkill.t())}
@spec update(PhoenixKitStaff.Schemas.Skill.t(), map()) :: {:ok, PhoenixKitStaff.Schemas.Skill.t()} | {:error, Ecto.Changeset.t(PhoenixKitStaff.Schemas.Skill.t())}
Updates a skill and broadcasts :skill_updated.
Reconciles existing assignments to the new levels/allow_multiple_levels
in the same transaction: ids removed from levels are stripped from every
person_skills.proficiency_levels, and if multiple→single each assignment is
pruned to its first level (in skill order). This keeps assignments free of
orphaned or over-count ids.
Takes a FOR UPDATE lock on the skill row first so a concurrent assignment
write (which locks the same row) can't validate against the old levels and
then insert a now-removed level id — the two serialize on the lock.
@spec update_assignment_levels( PhoenixKitStaff.Schemas.PersonSkill.t(), [String.t()] | String.t() | nil ) :: {:ok, PhoenixKitStaff.Schemas.PersonSkill.t()} | {:error, :skill_not_found | :invalid_levels | :too_many_levels | Ecto.Changeset.t()}
Replaces an assignment's selected levels (a list of level ids); broadcasts
:person_skill_updated. Validates against the parent skill, under the same
FOR UPDATE skill-row lock as assign_skill/3.
@spec validate_level_ids(PhoenixKitStaff.Schemas.Skill.t(), [String.t()]) :: {:ok, [String.t()]} | {:error, :invalid_levels | :too_many_levels}
Validates a list of level ids against a skill: every id must exist in the
skill's levels, and there must be ≤1 unless allow_multiple_levels. Returns
the ids cleaned + reordered to the skill's level order on success.