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
@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 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_task_dependencies(uuid()) :: [PhoenixKitProjects.Schemas.Task.t()]
Tasks that the given task does not yet depend on (for the dependency picker).
@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() :: non_neg_integer()
Total number of projects (including templates).
@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_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()}
Deletes an assignment and broadcasts :assignment_deleted.
@spec delete_project(PhoenixKitProjects.Schemas.Project.t()) :: {:ok, PhoenixKitProjects.Schemas.Project.t()} | {:error, Ecto.Changeset.t()}
Deletes a project and broadcasts :project_deleted.
@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 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_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 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() :: [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.
@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).
@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 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.
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).
Same as reorder_projects/2 but scoped to is_template = true.
Audit rows use template.reordered so the activity feed
distinguishes the two.
@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()) :: {: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()) :: {:ok, PhoenixKitProjects.Schemas.Project.t()} | {:error, Ecto.Changeset.t()}
Updates a project and broadcasts :project_updated.
@spec update_task(PhoenixKitProjects.Schemas.Task.t(), map()) :: {:ok, PhoenixKitProjects.Schemas.Task.t()} | {:error, Ecto.Changeset.t()}
Updates a task and broadcasts :task_updated.