Context for projects, tasks, assignments, and dependencies.
Summary
Types
A node in the task-template dependency closure tree.
Atom-shaped error returned for not-found / missing-resource cases.
UUIDv7 string or raw 16-byte binary (Ecto accepts either).
Functions
Adds an assignment-level dependency and broadcasts :dependency_added.
Adds a template-level dependency from one task to another.
When an assignment is created, auto-create assignment-level dependencies from the task template's defaults (linking to sibling assignments already in the same project).
Soft-hides the project by stamping archived_at. Idempotent — re-archiving rewrites the timestamp.
Counts of assignments by status across active non-template projects. Returns a map like %{"todo" => 5, "in_progress" => 2, "done" => 10}.
Assignments in this project that the given assignment does NOT yet depend on.
Projects eligible to be linked as a sub-project of parent (V127): standalone
(not already a sub-project), same is_template, not archived, not the parent,
and not one of the parent's ancestors (cycle-safe). Ordered by name for a
picker.
Tasks that the given task does not yet depend on (for the dependency picker).
Broadcasts a :project_updated event for a project (post-commit use).
Returns a changeset for the given assignment.
Returns a changeset for the given project.
Returns a changeset for the given task.
Returns %{assignment_uuid => published_comment_count} for the
given assignment uuids. Single grouped query (no N queries).
Marks an assignment done, stamping completed_by_uuid and completed_at.
Count of projects matching the given filter opts. Defaults match
list_projects/1 (excludes templates, excludes archived) so the
count is paired with the list — sized "Showing X of Y" copy stays
honest about both numbers.
Total number of tasks in the library.
Total number of template projects.
Inserts an assignment and broadcasts :assignment_created.
Creates an assignment pre-populated from the task template's defaults (description, duration, default assignee). The caller's attrs override any template values.
Creates the root assignment AND any closure-pulled assignments in one
serializable transaction, then wires Dependency rows according to
the TaskDependency graph between the resulting assignments.
Inserts a project and broadcasts :project_created.
Creates a new project by cloning a template. Copies all assignments (with their task links, descriptions, durations, assignees, weekends settings) and re-creates dependencies between the cloned assignments.
Adds a sub-project to parent_project_uuid: creates a fresh child
project (named via child_attrs) and links it into the parent's timeline as
an assignment whose child_project_uuid points at the new child. The child
starts empty (immediate-start); add its own tasks by opening it. The linking
assignment behaves like any task — drag, dependencies — and its
status/progress/hours roll up from the child (see child_project_rollup/1).
Inserts a task and broadcasts :task_created.
Deletes an assignment and broadcasts :assignment_deleted.
Deletes a project and broadcasts :project_deleted.
Deletes a task and broadcasts :task_deleted.
Check if all dependencies of an assignment are done.
Detaches a sub-project from its parent (V127) — the non-destructive inverse of
the cascade in delete_assignment/1. Deletes only the linking assignment,
leaving the child project + its whole subtree intact; it becomes a standalone
top-level project again. Recomputes the former parent (one fewer rolled-up
item).
Flattens a closure_node() tree into a %{task_uuid => %Task{}} map.
Fetches an assignment by uuid with related records preloaded, or nil if not found.
Fetches a project by uuid, or nil if not found.
Fetches a project by uuid. Raises if not found.
Fetches a project with its assignee associations preloaded (V128).
Fetches multiple projects by uuid in a single query, returning a
%{uuid => Project.t()} map. Missing uuids are silently dropped from
the result rather than raising — callers can detect them by checking
Map.has_key?/2 or comparing the result's keys to the input list.
Fetches a task by uuid, or nil if not found.
Fetches a task by uuid. Raises if not found.
Links an existing standalone project into parent_project_uuid as a
sub-project (V127) — the "nest this project" path, alongside the create-new
create_subproject/2. The child keeps its whole subtree, and drops off the
top-level list once linked.
Running projects (started, not archived, not yet completed).
All dependencies across every assignment in a project (used when cloning templates).
Lists assignments within a project, ordered by position, with related records preloaded.
Assignments currently assigned to the given user's staff record. Returns non-done assignments across all active projects, with project preloaded.
Dependencies declared on a single assignment.
Lists projects.
Completed projects (all tasks done), most recently completed first.
Projects not yet started, in setup (immediate mode, not scheduled).
Template-level dependencies declared on the given task.
Returns task-library "groups" — rooted dependency trees.
Lists all task-library entries, preloaded with defaults.
Returns the flat task list with a %{task_uuid => [Task]} map of
the directed TaskDependency edges (task → depends_on_task) for
badge rendering. Used by the task-library list view (the default
view mode).
Lists projects that are templates, in position-then-date-added
order. Date-added (not name) is the secondary sort so renaming a
template doesn't shuffle it in the list. After a manual drag,
templates with explicit positions land at the top in the user's
order; un-touched templates fall to the bottom by date-added.
uuid tiebreaks within the same inserted_at second.
Scheduled projects waiting to start.
Next available position for a new assignment within the given
project — one past the current per-project max, falling back to
1 on an empty project.
Next available position within the given is_template scope —
one past the per-bucket max, falling back to 1 on an empty
bucket. Projects (is_template = false) and templates (true)
share the column but order independently.
Next available position for a new task — one past the current
max, falling back to 1 on an empty table. New tasks should be
inserted with this value so they land at the bottom of the
user's manual order.
Batched summaries for many projects — loads all their assignments in one query, then groups in memory. Preserves the input project order.
Summary of an active project for the overview dashboard. Returns a map with progress stats.
Recursive tree summary for a project: its own task breakdown plus a nested summary for each embedded sub-project, all the way down (V127). Powers the hierarchical dashboard card.
Called after an assignment status change. Checks whether all assignments
in the project are done. If so, sets completed_at. If not (e.g., a task
was reopened), clears it. Returns the (possibly updated) project.
Removes an assignment-level dependency and broadcasts :dependency_removed.
Removes a template-level dependency. Returns {:error, :not_found} if missing.
Reverts an assignment to todo and clears its completion fields.
Re-indexes the supplied assignment uuids into positions 1..N
within the given project. Used by the project-show timeline DnD
handler.
Re-indexes the supplied project uuids into positions 1..N.
Used by the project list-view DnD handler.
Bulk-reorder projects by a sort strategy.
Re-indexes the supplied task uuids into positions 1..N. Used by
the task-library list-view DnD handler.
Bulk-reorder tasks by strategy. Strategy :name_asc / :name_desc
sorts by title (tasks don't have a name field).
Same as reorder_projects/2 but scoped to is_template = true.
Audit rows use template.reordered so the activity feed
distinguishes the two.
Bulk-reorder templates by strategy. Same shape as
reorder_projects_by/3 but scoped to is_template = true.
Fetches the assignments among uuids that belong to project_uuid, in a
single query. Used to authorize multi-endpoint operations (e.g. dependency
removal, which must confirm BOTH endpoints live in the viewed project)
without one round-trip per uuid. No preloads — callers only check scope.
Sets the project's current workflow-status slug and broadcasts
:project_status_changed. Uses the dedicated, server-owned
Project.current_status_changeset/2 (never the form changeset) — go
through PhoenixKitProjects.Statuses.set_current_status/3 rather than
calling this directly, so the slug is validated against the project's
status list first. nil clears the selection.
Stamps started_at on the project and broadcasts :project_started.
Builds the task-template dependency closure rooted at root_task_uuid.
Restores an archived project by clearing archived_at.
Form-safe update for user-submitted attrs. Does NOT apply completion
fields (completed_by_uuid, completed_at) even if they appear in
attrs — they are silently dropped by Assignment.changeset/2.
Server-trusted update that additionally casts completed_by_uuid and
completed_at. Only call from server code (never pass raw form attrs),
since the caller vouches for those fields.
Updates a project and broadcasts :project_updated.
Updates a task and broadcasts :task_updated.
Types
@type closure_node() :: %{ task: PhoenixKitProjects.Schemas.Task.t(), children: [closure_node()], cycle?: boolean(), already_in_project?: boolean() }
A node in the task-template dependency closure tree.
:task— theTaskschema struct, with task-level preloads.:children— child trees (the tasks this one depends on, transitively).:cycle?—trueif this node was reached via a cycle in the template graph and traversal stopped here.TaskDependencydoesn't enforce acyclicity, so this flag lets the UI render a warning instead of spinning forever.:already_in_project?—trueif an assignment for this task already exists in the target project. UI uses this to show the node as "already there" (won't be re-added on save) — applies to every node in the tree, including the root.
@type error_atom() :: :not_found | :template_not_found | :task_not_found
Atom-shaped error returned for not-found / missing-resource cases.
@type reorder_strategy() ::
:name_asc | :name_desc | :created_asc | :created_desc | :reverse
@type uuid() :: String.t() | <<_::128>>
UUIDv7 string or raw 16-byte binary (Ecto accepts either).
Functions
@spec add_dependency(uuid(), uuid()) :: {:ok, PhoenixKitProjects.Schemas.Dependency.t()} | {:error, Ecto.Changeset.t()}
Adds an assignment-level dependency and broadcasts :dependency_added.
Rejects any edge that would introduce a cycle — i.e., if depends_on_uuid
already (transitively) depends on assignment_uuid, the insert is
refused with a changeset error. The schema-level self-reference check
handles the A == B case; this function handles multi-hop cycles
(A → B, then B → A).
The cycle check + insert run inside a :serializable transaction.
Without this, two concurrent calls — add_dependency(A, B) and
add_dependency(B, A) — could each read an acyclic graph, both
pass the check, both insert, and produce a cycle (the unique pair
index doesn't catch this; it only rejects identical duplicate
edges). At :serializable Postgres aborts the loser with
serialization_failure (SQLSTATE 40001); we catch that and
return a friendly changeset error so the caller can retry.
When called from inside another transaction (e.g. via
create_project_from_template/2 → clone_template/2), the inner
repo().transaction/2 becomes a savepoint and Postgres ignores
the inner isolation: keyword — the protection only holds if the
outer transaction is itself opened at :serializable (which
clone_template/2 does).
@spec add_task_dependency(uuid(), uuid()) :: {:ok, PhoenixKitProjects.Schemas.TaskDependency.t()} | {:error, Ecto.Changeset.t()}
Adds a template-level dependency from one task to another.
@spec apply_template_dependencies(PhoenixKitProjects.Schemas.Assignment.t()) :: :ok | {:ok, term()} | {:error, term()}
When an assignment is created, auto-create assignment-level dependencies from the task template's defaults (linking to sibling assignments already in the same project).
Idempotent: duplicate (assignment_uuid, depends_on_uuid) pairs return
a unique-constraint changeset, which we translate into a no-op (already
exists is the desired end state). All inserts run in a single
transaction so a partial failure rolls the batch back.
@spec archive_project(PhoenixKitProjects.Schemas.Project.t()) :: {:ok, PhoenixKitProjects.Schemas.Project.t()} | {:error, Ecto.Changeset.t()}
Soft-hides the project by stamping archived_at. Idempotent — re-archiving rewrites the timestamp.
@spec assignment_status_counts() :: %{optional(String.t()) => non_neg_integer()}
Counts of assignments by status across active non-template projects. Returns a map like %{"todo" => 5, "in_progress" => 2, "done" => 10}.
Filters on is_nil(p.archived_at) to match the dashboard's intent —
assignments inside archived projects shouldn't inflate the workload
stats shown alongside list_active_projects/0.
@spec available_dependencies(uuid(), uuid()) :: [ PhoenixKitProjects.Schemas.Assignment.t() ]
Assignments in this project that the given assignment does NOT yet depend on.
@spec available_projects_to_link(PhoenixKitProjects.Schemas.Project.t()) :: [ PhoenixKitProjects.Schemas.Project.t() ]
Projects eligible to be linked as a sub-project of parent (V127): standalone
(not already a sub-project), same is_template, not archived, not the parent,
and not one of the parent's ancestors (cycle-safe). Ordered by name for a
picker.
@spec available_task_dependencies(uuid()) :: [PhoenixKitProjects.Schemas.Task.t()]
Tasks that the given task does not yet depend on (for the dependency picker).
@spec broadcast_project_updated(PhoenixKitProjects.Schemas.Project.t()) :: :ok
Broadcasts a :project_updated event for a project (post-commit use).
@spec change_assignment(PhoenixKitProjects.Schemas.Assignment.t(), map()) :: Ecto.Changeset.t()
Returns a changeset for the given assignment.
@spec change_project(PhoenixKitProjects.Schemas.Project.t(), map(), keyword()) :: Ecto.Changeset.t()
Returns a changeset for the given project.
Accepts the same opts as Project.changeset/3 — notably
:enforce_scheduled_date_required, which the project form passes as
false on phx-change events so the just-revealed date input doesn't
light up red before the user has had a chance to fill it.
@spec change_task(PhoenixKitProjects.Schemas.Task.t(), map()) :: Ecto.Changeset.t()
Returns a changeset for the given task.
@spec comment_counts_for_assignments([uuid()]) :: %{ optional(String.t()) => non_neg_integer() }
Returns %{assignment_uuid => published_comment_count} for the
given assignment uuids. Single grouped query (no N queries).
Returns %{} if the phoenix_kit_comments module isn't loaded
(host hasn't installed it) or if anything goes wrong at the query
level — the comments badge is purely informational, never blocking,
so any failure degrades silently.
@spec complete_assignment(PhoenixKitProjects.Schemas.Assignment.t(), uuid() | nil) :: {:ok, PhoenixKitProjects.Schemas.Assignment.t()} | {:error, Ecto.Changeset.t()}
Marks an assignment done, stamping completed_by_uuid and completed_at.
@spec count_projects(keyword()) :: non_neg_integer()
Count of projects matching the given filter opts. Defaults match
list_projects/1 (excludes templates, excludes archived) so the
count is paired with the list — sized "Showing X of Y" copy stays
honest about both numbers.
Pass include_templates: true to count both kinds, or
archived: :all to drop the archived filter.
@spec count_tasks() :: non_neg_integer()
Total number of tasks in the library.
@spec count_templates() :: non_neg_integer()
Total number of template projects.
@spec create_assignment(map()) :: {:ok, PhoenixKitProjects.Schemas.Assignment.t()} | {:error, Ecto.Changeset.t()}
Inserts an assignment and broadcasts :assignment_created.
@spec create_assignment_from_template(uuid(), map()) :: {:ok, PhoenixKitProjects.Schemas.Assignment.t()} | {:error, :task_not_found | Ecto.Changeset.t()}
Creates an assignment pre-populated from the task template's defaults (description, duration, default assignee). The caller's attrs override any template values.
@spec create_assignments_with_closure(uuid(), uuid(), map(), keyword()) :: {:ok, %{ root: PhoenixKitProjects.Schemas.Assignment.t(), extras: [PhoenixKitProjects.Schemas.Assignment.t()] }} | {:error, term()}
Creates the root assignment AND any closure-pulled assignments in one
serializable transaction, then wires Dependency rows according to
the TaskDependency graph between the resulting assignments.
Drives the assignment-form's "this task pulls in N more" UX: the user
picks a task, the closure tree shows what'll be dragged in, the user
optionally prunes nodes via excluded_task_uuids, and on save this
function lands the kept set atomically.
Parameters
root_task_uuid— the task the user explicitly picked.project_uuid— target project.attrs— the form'sassignmentparams, used for the root assignment's description/duration/assignee/etc. overrides.opts::excluded_task_uuids—MapSetof task uuids the user unticked in the closure tree. Excluded tasks are skipped, but their template-dep edges that touch kept tasks are still wired (so removing a leaf doesn't break upstream wiring). Defaults toMapSet.new().
Returns {:ok, %{root: assignment, extras: [assignment, ...]}} on
success or {:error, reason} on failure. The transaction rolls back
cleanly on any failure — partial closure inserts won't leak.
Tasks already represented by an assignment in the project are reused (not duplicated) when wiring deps; a closure node whose task already has an assignment becomes a wiring target without triggering an insert.
@spec create_project(map()) :: {:ok, PhoenixKitProjects.Schemas.Project.t()} | {:error, Ecto.Changeset.t()}
Inserts a project and broadcasts :project_created.
@spec create_project_from_template(uuid(), map()) :: {:ok, PhoenixKitProjects.Schemas.Project.t()} | {:error, :template_not_found | Ecto.Changeset.t() | term()}
Creates a new project by cloning a template. Copies all assignments (with their task links, descriptions, durations, assignees, weekends settings) and re-creates dependencies between the cloned assignments.
@spec create_subproject(uuid(), map()) :: {:ok, %{ child_project: PhoenixKitProjects.Schemas.Project.t(), assignment: PhoenixKitProjects.Schemas.Assignment.t() }} | {:error, :parent_not_found | Ecto.Changeset.t()}
Adds a sub-project to parent_project_uuid: creates a fresh child
project (named via child_attrs) and links it into the parent's timeline as
an assignment whose child_project_uuid points at the new child. The child
starts empty (immediate-start); add its own tasks by opening it. The linking
assignment behaves like any task — drag, dependencies — and its
status/progress/hours roll up from the child (see child_project_rollup/1).
The child inherits the parent's is_template flag: a sub-project added to
a template is itself a sub-template, and create_project_from_template/2
deep-clones the whole sub-template subtree into real child projects when the
parent template is instantiated.
Both inserts run in one transaction. Returns
{:ok, %{child_project: Project.t(), assignment: Assignment.t()}}.
Inline-only creation makes a cycle structurally impossible (the child is
brand-new and can't already be an ancestor), so no cycle guard runs here. A
future "link existing project as a sub-project" path MUST add an ancestor
check before assigning child_project_uuid.
@spec create_task(map()) :: {:ok, PhoenixKitProjects.Schemas.Task.t()} | {:error, Ecto.Changeset.t()}
Inserts a task and broadcasts :task_created.
@spec delete_assignment(PhoenixKitProjects.Schemas.Assignment.t()) :: {:ok, PhoenixKitProjects.Schemas.Assignment.t()} | {:error, Ecto.Changeset.t() | term()}
Deletes an assignment and broadcasts :assignment_deleted.
For a sub-project linking row (child_project_uuid set, V127) this tears
the child project subtree down too — "removing the sub-project task removes
the sub-project", matching the boss's "same as a task" semantics. The linking
row is deleted first (the child_project_uuid FK is ON DELETE RESTRICT, so
the child can't be removed while it's still referenced), then the child tree
via delete_project_tree_in_tx/1. Both in one transaction.
@spec delete_project(PhoenixKitProjects.Schemas.Project.t()) :: {:ok, PhoenixKitProjects.Schemas.Project.t()} | {:error, :still_a_subproject | term()}
Deletes a project and broadcasts :project_deleted.
Recursive over sub-projects (V127): any project embedded in p's
timeline (and, transitively, in theirs) is torn down too. Deleting p
DB-cascades its own assignment rows (project_uuid is ON DELETE CASCADE),
including the sub-project linking rows — but the child projects those rows
pointed at would be left orphaned, so they're deleted explicitly here. The
whole subtree comes down in one transaction; :project_deleted is broadcast
for every project removed.
Refuses to delete a project that is itself embedded as a sub-project of
another project — its child_project_uuid FK is ON DELETE RESTRICT, so a
raw repo().delete would raise Ecto.ConstraintError rather than return a
tuple. Such a project is reached (and removed) through its parent's timeline,
or detach_subproject/1 first; deleting it standalone returns
{:error, :still_a_subproject}.
@spec delete_task(PhoenixKitProjects.Schemas.Task.t()) :: {:ok, PhoenixKitProjects.Schemas.Task.t()} | {:error, Ecto.Changeset.t()}
Deletes a task and broadcasts :task_deleted.
Check if all dependencies of an assignment are done.
@spec detach_subproject(PhoenixKitProjects.Schemas.Assignment.t()) :: {:ok, PhoenixKitProjects.Schemas.Assignment.t()} | {:error, term()}
Detaches a sub-project from its parent (V127) — the non-destructive inverse of
the cascade in delete_assignment/1. Deletes only the linking assignment,
leaving the child project + its whole subtree intact; it becomes a standalone
top-level project again. Recomputes the former parent (one fewer rolled-up
item).
@spec flatten_closure(closure_node() | nil) :: %{ optional(String.t()) => PhoenixKitProjects.Schemas.Task.t() }
Flattens a closure_node() tree into a %{task_uuid => %Task{}} map.
Used by the save path to enumerate every task that might become an
assignment (before applying the user's exclusions). Map rather than
list because the same task can appear multiple times across branches
if two parents both depend on it — the map dedups by uuid.
@spec get_assignment(uuid()) :: PhoenixKitProjects.Schemas.Assignment.t() | nil
Fetches an assignment by uuid with related records preloaded, or nil if not found.
@spec get_project(uuid()) :: PhoenixKitProjects.Schemas.Project.t() | nil
Fetches a project by uuid, or nil if not found.
@spec get_project!(uuid()) :: PhoenixKitProjects.Schemas.Project.t()
Fetches a project by uuid. Raises if not found.
@spec get_project_with_assignee(uuid()) :: PhoenixKitProjects.Schemas.Project.t() | nil
Fetches a project with its assignee associations preloaded (V128).
@spec get_projects([uuid() | nil]) :: %{ required(uuid()) => PhoenixKitProjects.Schemas.Project.t() }
Fetches multiple projects by uuid in a single query, returning a
%{uuid => Project.t()} map. Missing uuids are silently dropped from
the result rather than raising — callers can detect them by checking
Map.has_key?/2 or comparing the result's keys to the input list.
Avoids the per-row N+1 host apps would otherwise write to render a list of records with linked projects (e.g. an orders index page showing each row's project name). Nil + duplicate uuids in the input are tolerated.
Returns an empty map for an empty list.
Examples
iex> %{name: name} = Projects.get_projects([uuid_a, uuid_b])[uuid_a]
iex> name
"Alpha"
iex> Projects.get_projects([])
%{}
@spec get_task(uuid()) :: PhoenixKitProjects.Schemas.Task.t() | nil
Fetches a task by uuid, or nil if not found.
@spec get_task!(uuid()) :: PhoenixKitProjects.Schemas.Task.t()
Fetches a task by uuid. Raises if not found.
@spec link_subproject(uuid(), uuid()) :: {:ok, %{ child_project: PhoenixKitProjects.Schemas.Project.t(), assignment: PhoenixKitProjects.Schemas.Assignment.t() }} | {:error, :not_found | :self_link | :kind_mismatch | :already_subproject | :would_create_cycle | Ecto.Changeset.t()}
Links an existing standalone project into parent_project_uuid as a
sub-project (V127) — the "nest this project" path, alongside the create-new
create_subproject/2. The child keeps its whole subtree, and drops off the
top-level list once linked.
Guards: the child must exist, not be the parent, match the parent's
is_template, not already be a sub-project (the single-parent unique index),
and not be an ancestor of the parent (which would create a cycle). Errors:
:not_found, :self_link, :kind_mismatch, :already_subproject,
:would_create_cycle.
@spec list_active_projects() :: [PhoenixKitProjects.Schemas.Project.t()]
Running projects (started, not archived, not yet completed).
@spec list_all_dependencies(uuid()) :: [PhoenixKitProjects.Schemas.Dependency.t()]
All dependencies across every assignment in a project (used when cloning templates).
@spec list_assignments(uuid()) :: [PhoenixKitProjects.Schemas.Assignment.t()]
Lists assignments within a project, ordered by position, with related records preloaded.
@spec list_assignments_for_user(uuid()) :: [PhoenixKitProjects.Schemas.Assignment.t()]
Assignments currently assigned to the given user's staff record. Returns non-done assignments across all active projects, with project preloaded.
The rescue [Postgrex.Error, DBConnection.ConnectionError, Ecto.QueryError]
block at the bottom is intentional: a hard dep on
phoenix_kit_staff means a Staff outage (missing tables in early
install, sandbox-shutdown in tests, transient connection drop) would
otherwise take the Projects dashboard down. The rescue degrades
gracefully to "no assignments for this user" — the dashboard keeps
rendering for everyone else's data. Don't "clean it up" by
narrowing or removing.
@spec list_dependencies(uuid()) :: [PhoenixKitProjects.Schemas.Dependency.t()]
Dependencies declared on a single assignment.
@spec list_projects(keyword()) :: [PhoenixKitProjects.Schemas.Project.t()]
Lists projects.
Options:
:archived—false(default) hides archived;trueshows only archived;:allreturns both.:include_templates— defaultfalse.
@spec list_recently_completed_projects(pos_integer()) :: [ PhoenixKitProjects.Schemas.Project.t() ]
Completed projects (all tasks done), most recently completed first.
@spec list_setup_projects() :: [PhoenixKitProjects.Schemas.Project.t()]
Projects not yet started, in setup (immediate mode, not scheduled).
@spec list_task_dependencies(uuid()) :: [ PhoenixKitProjects.Schemas.TaskDependency.t() ]
Template-level dependencies declared on the given task.
@spec list_task_groups() :: %{ trees: [closure_node()], standalone: [PhoenixKitProjects.Schemas.Task.t()] }
Returns task-library "groups" — rooted dependency trees.
Each group has one root (a task that nothing else depends on,
i.e. has no incoming depends_on_task_uuid edge) plus its full
transitive closure of task-template deps. Tasks shared across
multiple roots appear in each group — duplication is the point
of the grouped view: it surfaces "this task is reused across N
workflows."
Tasks with no deps either way (no incoming and no outgoing edges)
are returned in :standalone instead of being padded out as
one-task groups, so the group view doesn't look like 50 tiny cards
for unrelated singletons.
Returns %{trees: [closure_node()], standalone: [Task.t()]} where
each closure_node() has :task, :children, :cycle?,
:already_in_project? (the last is false here — this view isn't
scoped to a project; the field is kept for shape compatibility with
task_closure/2).
@spec list_tasks(keyword()) :: [PhoenixKitProjects.Schemas.Task.t()]
Lists all task-library entries, preloaded with defaults.
Order: position ASC, inserted_at ASC. Date-added is the secondary
sort (NOT title) so renaming a task doesn't shuffle it in the list
— a complaint we got from the boss with the prior title-secondary
ordering. After a reorder, dragged tasks claim 1..N and appear
above any still-zero ones; among the still-zero tasks, creation
order wins.
Returns the flat task list with a %{task_uuid => [Task]} map of
the directed TaskDependency edges (task → depends_on_task) for
badge rendering. Used by the task-library list view (the default
view mode).
@spec list_templates() :: [PhoenixKitProjects.Schemas.Project.t()]
Lists projects that are templates, in position-then-date-added
order. Date-added (not name) is the secondary sort so renaming a
template doesn't shuffle it in the list. After a manual drag,
templates with explicit positions land at the top in the user's
order; un-touched templates fall to the bottom by date-added.
uuid tiebreaks within the same inserted_at second.
@spec list_upcoming_projects() :: [PhoenixKitProjects.Schemas.Project.t()]
Scheduled projects waiting to start.
Next available position for a new assignment within the given
project — one past the current per-project max, falling back to
1 on an empty project.
Next available position within the given is_template scope —
one past the per-bucket max, falling back to 1 on an empty
bucket. Projects (is_template = false) and templates (true)
share the column but order independently.
@spec next_task_position() :: integer()
Next available position for a new task — one past the current
max, falling back to 1 on an empty table. New tasks should be
inserted with this value so they land at the bottom of the
user's manual order.
@spec project_summaries([PhoenixKitProjects.Schemas.Project.t()]) :: [map()]
Batched summaries for many projects — loads all their assignments in one query, then groups in memory. Preserves the input project order.
@spec project_summary(PhoenixKitProjects.Schemas.Project.t()) :: map() | nil
Summary of an active project for the overview dashboard. Returns a map with progress stats.
@spec project_tree_summary(PhoenixKitProjects.Schemas.Project.t()) :: map()
Recursive tree summary for a project: its own task breakdown plus a nested summary for each embedded sub-project, all the way down (V127). Powers the hierarchical dashboard card.
Each node has the shape:
%{
project: %Project{},
task_total: integer, # direct tasks (assignments with a task)
task_done / task_in_progress / task_todo: integer,
subproject_count: integer, # direct sub-projects
total: integer, # task_total + subproject_count
progress_pct: integer, # tasks (done=100) + non-empty children
total_hours: float,
planned_end: DateTime.t() | nil,
children: [node, ...] # one per sub-project, same shape
}total + progress_pct + planned_end are kept so the existing dashboard
tier/sort helpers read a node like the flat project_summaries map. The
progress average counts each real task (done = 100, else its slider) and each
non-empty child's rolled progress — empty sub-projects are neutral.
One list_assignments/1 per node; depth is bounded by the (acyclic) tree, so
it's fine for the handful of running projects on the dashboard.
@spec recompute_project_completion(uuid()) :: :ok | {:completed, PhoenixKitProjects.Schemas.Project.t()} | {:reopened, PhoenixKitProjects.Schemas.Project.t()} | {:unchanged, PhoenixKitProjects.Schemas.Project.t()} | {:error, term()}
Called after an assignment status change. Checks whether all assignments
in the project are done. If so, sets completed_at. If not (e.g., a task
was reopened), clears it. Returns the (possibly updated) project.
@spec remove_dependency(uuid(), uuid()) :: {:ok, PhoenixKitProjects.Schemas.Dependency.t()} | {:error, :not_found | Ecto.Changeset.t()}
Removes an assignment-level dependency and broadcasts :dependency_removed.
@spec remove_task_dependency(uuid(), uuid()) :: {:ok, PhoenixKitProjects.Schemas.TaskDependency.t()} | {:error, :not_found | Ecto.Changeset.t()}
Removes a template-level dependency. Returns {:error, :not_found} if missing.
@spec reopen_assignment(PhoenixKitProjects.Schemas.Assignment.t()) :: {:ok, PhoenixKitProjects.Schemas.Assignment.t()} | {:error, Ecto.Changeset.t()}
Reverts an assignment to todo and clears its completion fields.
@spec reorder_assignments(uuid(), [uuid()], keyword()) :: :ok | {:error, :too_many_uuids | :not_in_project | term()}
Re-indexes the supplied assignment uuids into positions 1..N
within the given project. Used by the project-show timeline DnD
handler.
All uuids must belong to project_uuid — UUIDs that exist in
another project (or don't exist) abort the whole batch with
{:error, :not_in_project}. Duplicates in the input are deduped
last-write-wins.
Two-pass write inside a transaction (negatives → positives) so
any future unique index on (project_uuid, position) would be
honoured. Returns :ok / {:error, :too_many_uuids} /
{:error, :not_in_project} / {:error, term()}. Audit rows are
written for every outcome.
The @reorder_max_uuids cap is checked against the raw input
list length, before dedup — a payload over the cap signals a
misbehaving client (real users can't drag 1000+ rows in one
batched event), so the rejection is a guard, not a real-user
constraint. Same shape as reorder_tasks/2.
Re-indexes the supplied project uuids into positions 1..N.
Used by the project list-view DnD handler.
Scope: is_template = false. UUIDs that resolve to templates (or
to no row at all) abort the whole batch with
{:error, :wrong_scope}. Duplicates dedup last-write-wins.
Two-pass write (negatives → positives) inside a transaction.
The @reorder_max_uuids cap is checked against the raw input
list length, before dedup — a payload over the cap signals a
misbehaving client (real users can't drag 1000+ rows in one
batched event), so the rejection is a guard, not a real-user
constraint. Same shape as reorder_tasks/2.
@spec reorder_projects_by(reorder_strategy(), :all | [uuid()], keyword()) :: :ok | {:error, :wrong_scope | :too_many_uuids | term()}
Bulk-reorder projects by a sort strategy.
scope = :all— load every non-template project, sort by strategy, write contiguous 1..N positions via the existingReorder.reorderprimitive.scope = [uuid, ...]— load only those projects, validate they are all non-templates, permute them within the slots they already occupy (their existingpositionvalues). Untouched projects are not re-indexed.
Returns :ok, {:error, :wrong_scope} if any uuid maps to a
template or unknown row, {:error, :too_many_uuids} past the cap,
or {:error, term} on DB failure. Audit row logged on success.
Re-indexes the supplied task uuids into positions 1..N. Used by
the task-library list-view DnD handler.
No parent scope — the task library is a flat collection. UUIDs not found in the table are dropped silently (the LV may have a stale view of the page when the user dragged). Duplicates in the input list are deduped last-write-wins.
Two-pass write inside a transaction: pass 1 stamps position = -idx, pass 2 stamps position = idx. Sidesteps any future unique
index on position and stays atomic.
The @reorder_max_uuids cap is checked against the raw input
list length, before dedup — a payload over the cap signals a
misbehaving client (real users can't drag 1000+ rows in one
batched event), so the rejection is a guard, not a real-user
constraint.
Returns :ok on success, {:error, :too_many_uuids} past the cap,
or {:error, reason} on a DB failure. Audit rows are written for
every outcome (success carries the count + first-uuid; rejection
paths log via log_reorder_rejected/3 shape).
@spec reorder_tasks_by(reorder_strategy(), :all | [uuid()], keyword()) :: :ok | {:error, :too_many_uuids | term()}
Bulk-reorder tasks by strategy. Strategy :name_asc / :name_desc
sorts by title (tasks don't have a name field).
Same as reorder_projects/2 but scoped to is_template = true.
Audit rows use template.reordered so the activity feed
distinguishes the two.
@spec reorder_templates_by(reorder_strategy(), :all | [uuid()], keyword()) :: :ok | {:error, :wrong_scope | :too_many_uuids | term()}
Bulk-reorder templates by strategy. Same shape as
reorder_projects_by/3 but scoped to is_template = true.
@spec scoped_assignments([uuid()], uuid()) :: [ PhoenixKitProjects.Schemas.Assignment.t() ]
Fetches the assignments among uuids that belong to project_uuid, in a
single query. Used to authorize multi-endpoint operations (e.g. dependency
removal, which must confirm BOTH endpoints live in the viewed project)
without one round-trip per uuid. No preloads — callers only check scope.
@spec set_current_status_slug( PhoenixKitProjects.Schemas.Project.t(), String.t() | nil ) :: {:ok, PhoenixKitProjects.Schemas.Project.t()} | {:error, Ecto.Changeset.t()}
Sets the project's current workflow-status slug and broadcasts
:project_status_changed. Uses the dedicated, server-owned
Project.current_status_changeset/2 (never the form changeset) — go
through PhoenixKitProjects.Statuses.set_current_status/3 rather than
calling this directly, so the slug is validated against the project's
status list first. nil clears the selection.
@spec start_project(PhoenixKitProjects.Schemas.Project.t(), DateTime.t() | nil) :: {:ok, PhoenixKitProjects.Schemas.Project.t()} | {:error, Ecto.Changeset.t()}
Stamps started_at on the project and broadcasts :project_started.
started_at defaults to DateTime.utc_now(). Pass a %DateTime{} to
backdate (the user picked an earlier date in the start-project modal)
or future-date (the project is being prepared but the actual start
is later than today).
@spec task_closure(uuid(), uuid()) :: closure_node() | nil
Builds the task-template dependency closure rooted at root_task_uuid.
Traverses TaskDependency edges (task → depends_on_task) outward
from the root, returning a tree the UI can render with checkboxes
for pruning. project_uuid is used to mark which nodes already have
an assignment in the target project — those are skipped on save
(the assignment-form's "drag in closure" flow won't duplicate them).
Returns nil if root_task_uuid doesn't resolve to a task. Cycles
in the template graph are detected and short-circuited; the cycle
node has cycle?: true and no children.
@spec unarchive_project(PhoenixKitProjects.Schemas.Project.t()) :: {:ok, PhoenixKitProjects.Schemas.Project.t()} | {:error, Ecto.Changeset.t()}
Restores an archived project by clearing archived_at.
@spec update_assignment_form( PhoenixKitProjects.Schemas.Assignment.t(), map(), keyword() ) :: {:ok, PhoenixKitProjects.Schemas.Assignment.t()} | {:error, Ecto.Changeset.t()}
Form-safe update for user-submitted attrs. Does NOT apply completion
fields (completed_by_uuid, completed_at) even if they appear in
attrs — they are silently dropped by Assignment.changeset/2.
Use update_assignment_status/2 instead when updating from server
code that legitimately owns those fields (completion transitions,
progress updates).
The _form suffix is a deliberate smell: if you reach for this
function, double-check whether your caller is really a form handler.
@spec update_assignment_status(PhoenixKitProjects.Schemas.Assignment.t(), map()) :: {:ok, PhoenixKitProjects.Schemas.Assignment.t()} | {:error, Ecto.Changeset.t()}
Server-trusted update that additionally casts completed_by_uuid and
completed_at. Only call from server code (never pass raw form attrs),
since the caller vouches for those fields.
@spec update_project(PhoenixKitProjects.Schemas.Project.t(), map(), keyword()) :: {:ok, PhoenixKitProjects.Schemas.Project.t()} | {:error, Ecto.Changeset.t()}
Updates a project and broadcasts :project_updated.
Pass broadcast: false to skip the broadcast — used by callers that run
the update inside a larger transaction (e.g.
PhoenixKitProjects.Statuses.update_project_with_statuses/2) and must
defer the broadcast until after commit, so a later rollback can't leak a
phantom :project_updated to subscribers. Such callers fire the event
themselves via broadcast_project_updated/1 once the transaction commits.
@spec update_task(PhoenixKitProjects.Schemas.Task.t(), map(), keyword()) :: {:ok, PhoenixKitProjects.Schemas.Task.t()} | {:error, Ecto.Changeset.t()}
Updates a task and broadcasts :task_updated.
Pass broadcast: false to skip the broadcast — used by callers that write
inside their own transaction (e.g. the AI-translation adapter) and don't
want a :task_updated to fire before commit / masquerade as a user edit.