PhoenixKitProjects.Projects (PhoenixKitProjects v0.2.2)

Copy Markdown View Source

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.

Tasks that the given task does not yet depend on (for the dependency picker).

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.

Total number of projects (including templates).

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.

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.

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 task by uuid, or nil if not found.

Fetches a task by uuid. Raises if not found.

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.

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.

Re-indexes the supplied task uuids into positions 1..N. Used by the task-library list-view DnD handler.

Same as reorder_projects/2 but scoped to is_template = true. Audit rows use template.reordered so the activity feed distinguishes the two.

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

closure_node()

@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 — the Task schema struct, with task-level preloads.
  • :children — child trees (the tasks this one depends on, transitively).
  • :cycle?true if this node was reached via a cycle in the template graph and traversal stopped here. TaskDependency doesn't enforce acyclicity, so this flag lets the UI render a warning instead of spinning forever.
  • :already_in_project?true if 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.

error_atom()

@type error_atom() :: :not_found | :template_not_found | :task_not_found

Atom-shaped error returned for not-found / missing-resource cases.

uuid()

@type uuid() :: String.t() | <<_::128>>

UUIDv7 string or raw 16-byte binary (Ecto accepts either).

Functions

add_dependency(assignment_uuid, depends_on_uuid)

@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/2clone_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).

add_task_dependency(task_uuid, depends_on_task_uuid)

@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.

apply_template_dependencies(assignment)

@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.

archive_project(p)

Soft-hides the project by stamping archived_at. Idempotent — re-archiving rewrites the timestamp.

assignment_status_counts()

@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.

available_dependencies(project_uuid, assignment_uuid)

@spec available_dependencies(uuid(), uuid()) :: [
  PhoenixKitProjects.Schemas.Assignment.t()
]

Assignments in this project that the given assignment does NOT yet depend on.

available_task_dependencies(task_uuid)

@spec available_task_dependencies(uuid()) :: [PhoenixKitProjects.Schemas.Task.t()]

Tasks that the given task does not yet depend on (for the dependency picker).

change_assignment(a, attrs \\ %{})

@spec change_assignment(PhoenixKitProjects.Schemas.Assignment.t(), map()) ::
  Ecto.Changeset.t()

Returns a changeset for the given assignment.

change_project(p, attrs \\ %{}, opts \\ [])

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.

change_task(t, attrs \\ %{})

Returns a changeset for the given task.

comment_counts_for_assignments(assignment_uuids)

@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.

complete_assignment(a, completed_by_uuid)

@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.

count_projects()

@spec count_projects() :: non_neg_integer()

Total number of projects (including templates).

count_tasks()

@spec count_tasks() :: non_neg_integer()

Total number of tasks in the library.

count_templates()

@spec count_templates() :: non_neg_integer()

Total number of template projects.

create_assignment(attrs)

@spec create_assignment(map()) ::
  {:ok, PhoenixKitProjects.Schemas.Assignment.t()}
  | {:error, Ecto.Changeset.t()}

Inserts an assignment and broadcasts :assignment_created.

create_assignment_from_template(task_uuid, attrs)

@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.

create_assignments_with_closure(root_task_uuid, project_uuid, attrs, opts \\ [])

@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's assignment params, used for the root assignment's description/duration/assignee/etc. overrides.
  • opts:
    • :excluded_task_uuidsMapSet of 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 to MapSet.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.

create_project(attrs)

@spec create_project(map()) ::
  {:ok, PhoenixKitProjects.Schemas.Project.t()} | {:error, Ecto.Changeset.t()}

Inserts a project and broadcasts :project_created.

create_project_from_template(template_uuid, project_attrs)

@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.

create_task(attrs)

@spec create_task(map()) ::
  {:ok, PhoenixKitProjects.Schemas.Task.t()} | {:error, Ecto.Changeset.t()}

Inserts a task and broadcasts :task_created.

delete_assignment(a)

Deletes an assignment and broadcasts :assignment_deleted.

delete_project(p)

Deletes a project and broadcasts :project_deleted.

delete_task(t)

Deletes a task and broadcasts :task_deleted.

dependencies_met?(assignment_uuid)

@spec dependencies_met?(uuid()) :: boolean()

Check if all dependencies of an assignment are done.

flatten_closure(arg1)

@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.

get_assignment(uuid)

@spec get_assignment(uuid()) :: PhoenixKitProjects.Schemas.Assignment.t() | nil

Fetches an assignment by uuid with related records preloaded, or nil if not found.

get_project(uuid)

@spec get_project(uuid()) :: PhoenixKitProjects.Schemas.Project.t() | nil

Fetches a project by uuid, or nil if not found.

get_project!(uuid)

@spec get_project!(uuid()) :: PhoenixKitProjects.Schemas.Project.t()

Fetches a project by uuid. Raises if not found.

get_task(uuid)

@spec get_task(uuid()) :: PhoenixKitProjects.Schemas.Task.t() | nil

Fetches a task by uuid, or nil if not found.

get_task!(uuid)

@spec get_task!(uuid()) :: PhoenixKitProjects.Schemas.Task.t()

Fetches a task by uuid. Raises if not found.

list_active_projects()

@spec list_active_projects() :: [PhoenixKitProjects.Schemas.Project.t()]

Running projects (started, not archived, not yet completed).

list_all_dependencies(project_uuid)

@spec list_all_dependencies(uuid()) :: [PhoenixKitProjects.Schemas.Dependency.t()]

All dependencies across every assignment in a project (used when cloning templates).

list_assignments(project_uuid)

@spec list_assignments(uuid()) :: [PhoenixKitProjects.Schemas.Assignment.t()]

Lists assignments within a project, ordered by position, with related records preloaded.

list_assignments_for_user(user_uuid)

@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.

list_dependencies(assignment_uuid)

@spec list_dependencies(uuid()) :: [PhoenixKitProjects.Schemas.Dependency.t()]

Dependencies declared on a single assignment.

list_projects(opts \\ [])

@spec list_projects(keyword()) :: [PhoenixKitProjects.Schemas.Project.t()]

Lists projects.

Options:

  • :archivedfalse (default) hides archived; true shows only archived; :all returns both.
  • :include_templates — default false.

list_recently_completed_projects(limit \\ 5)

@spec list_recently_completed_projects(pos_integer()) :: [
  PhoenixKitProjects.Schemas.Project.t()
]

Completed projects (all tasks done), most recently completed first.

list_setup_projects()

@spec list_setup_projects() :: [PhoenixKitProjects.Schemas.Project.t()]

Projects not yet started, in setup (immediate mode, not scheduled).

list_task_dependencies(task_uuid)

@spec list_task_dependencies(uuid()) :: [
  PhoenixKitProjects.Schemas.TaskDependency.t()
]

Template-level dependencies declared on the given task.

list_task_groups()

@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).

list_tasks()

@spec list_tasks() :: [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.

list_tasks_with_deps()

@spec list_tasks_with_deps() :: %{
  tasks: [PhoenixKitProjects.Schemas.Task.t()],
  deps_by_task: %{optional(String.t()) => [PhoenixKitProjects.Schemas.Task.t()]}
}

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).

list_templates()

@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.

list_upcoming_projects()

@spec list_upcoming_projects() :: [PhoenixKitProjects.Schemas.Project.t()]

Scheduled projects waiting to start.

next_assignment_position(project_uuid)

@spec next_assignment_position(uuid() | nil) :: integer()

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_project_position(is_template?)

@spec next_project_position(boolean()) :: integer()

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_task_position()

@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.

project_summaries(projects)

@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.

project_summary(project)

@spec project_summary(PhoenixKitProjects.Schemas.Project.t()) :: map() | nil

Summary of an active project for the overview dashboard. Returns a map with progress stats.

recompute_project_completion(project_uuid)

@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.

remove_dependency(assignment_uuid, depends_on_uuid)

@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.

remove_task_dependency(task_uuid, depends_on_task_uuid)

@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.

reopen_assignment(a)

Reverts an assignment to todo and clears its completion fields.

reorder_assignments(project_uuid, ordered_uuids, opts \\ [])

@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.

reorder_projects(ordered_uuids, opts \\ [])

@spec reorder_projects(
  [uuid()],
  keyword()
) :: :ok | {:error, :too_many_uuids | :wrong_scope | term()}

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.

reorder_tasks(ordered_uuids, opts \\ [])

@spec reorder_tasks(
  [uuid()],
  keyword()
) :: :ok | {:error, :too_many_uuids | term()}

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).

reorder_templates(ordered_uuids, opts \\ [])

@spec reorder_templates(
  [uuid()],
  keyword()
) :: :ok | {:error, :too_many_uuids | :wrong_scope | term()}

Same as reorder_projects/2 but scoped to is_template = true. Audit rows use template.reordered so the activity feed distinguishes the two.

start_project(p, started_at \\ nil)

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).

task_closure(root_task_uuid, project_uuid)

@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.

unarchive_project(p)

Restores an archived project by clearing archived_at.

update_assignment_form(a, attrs)

@spec update_assignment_form(PhoenixKitProjects.Schemas.Assignment.t(), map()) ::
  {: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.

update_assignment_status(a, attrs)

@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.

update_project(p, attrs)

Updates a project and broadcasts :project_updated.

update_task(t, attrs)

Updates a task and broadcasts :task_updated.