# `Lightning.Projects`
[🔗](https://github.com/OpenFn/lightning/blob/main/lib/lightning/projects.ex#L1)

The Projects context.

# `access_root_for_user`

```elixir
@spec access_root_for_user(
  Lightning.Projects.Project.t(),
  Lightning.Accounts.User.t()
) ::
  Lightning.Projects.Project.t()
```

Topmost ancestor of `project` that `user` can see; falls back to `project`.

# `add_project_users`

```elixir
@spec add_project_users(
  Lightning.Projects.Project.t(),
  [map(), ...],
  Lightning.Accounts.User.t(),
  boolean()
) ::
  {:ok, [Lightning.Projects.ProjectUser.t(), ...]}
  | {:error, Ecto.Changeset.t()}
```

# `cancel_scheduled_deletion`

# `change_project`

Returns an `%Ecto.Changeset{}` for tracking project changes.

## Examples

    iex> change_project(project)
    %Ecto.Changeset{data: %Project{}}

# `create_project`

Creates a project.

## Examples

    iex> create_project(%{field: value})
    {:ok, %Project{}}

    iex> create_project(%{field: bad_value})
    {:error, %Ecto.Changeset{}}

# `create_sandbox`

```elixir
@spec create_sandbox(Lightning.Projects.Project.t(), map(), boolean()) ::
  {:ok, Lightning.Projects.Project.t()} | {:error, Ecto.Changeset.t()}
```

Creates a sandbox under the given `parent` by delegating to `create_project/2`.

This is a convenience wrapper that sets `:parent_id` and preserves the
existing behavior around collaborator emails (off by default unless `schedule_email?` is `true`).

## Notes

* Child names are scoped-unique by `(parent_id, name)`. Root names may repeat,
  but two siblings cannot share a name (enforced by the `projects_unique_child_name` index).
* This function does **not** clone workflows, credentials, or dataclips. It only creates
  a new project row with `parent_id` set. See sandbox provisioning flow for full cloning.

## Returns

* `{:ok, %Project{}}` on success
* `{:error, %Ecto.Changeset{}}` on validation/unique errors

# `delete_project`

Deletes a project and its related data, including workflows, work orders,
steps, jobs, runs, triggers, project users, project credentials, and dataclips

## Examples

    iex> delete_project(project)
    {:ok, %Project{}}

    iex> delete_project(project)
    {:error, %Ecto.Changeset{}}

# `delete_project_async`

```elixir
@spec delete_project_async(Lightning.Projects.Project.t()) :: {:ok, Oban.Job.t()}
```

# `delete_project_dataclips`

```elixir
@spec delete_project_dataclips(Lightning.Projects.Project.t(), non_neg_integer()) ::
  :ok
```

Deletes project dataclips in batches

# `delete_project_user!`

```elixir
@spec delete_project_user!(
  Lightning.Projects.ProjectUser.t(),
  Lightning.Accounts.User.t()
) ::
  Lightning.Projects.ProjectUser.t()
```

Removes a collaborator from a project, revoking their project credentials,
auditing the removal against `actor` and broadcasting the change.

Refuses to remove the project owner, or an admin of the parent project from a
sandbox — neither is expressible through the project form, so both raise.

## Parameters
  - `project_user`: The `ProjectUser` struct to be deleted
  - `actor`: The `User` removing them, recorded on the audit event

## Returns
  - The deleted `ProjectUser` struct

# `delete_project_workorders`

```elixir
@spec delete_project_workorders(Lightning.Projects.Project.t(), non_neg_integer()) ::
  :ok
```

Deletes project work orders in batches

# `delete_sandbox`

```elixir
@spec delete_sandbox(
  Lightning.Projects.Project.t() | Ecto.UUID.t(),
  Lightning.Accounts.User.t()
) ::
  {:ok, Lightning.Projects.Project.t()}
  | {:error, :unauthorized | :not_found | term()}
```

Deletes a sandbox and all its descendant projects.

**Warning**: Permanently removes the sandbox and any nested sandboxes.

## Parameters
* `sandbox` - Sandbox to delete (project struct or ID string)
* `actor` - User performing deletion (needs `:owner` or `:admin` role on sandbox)

## Returns
* `{:ok, deleted_sandbox}` - Successfully deleted
* `{:error, :unauthorized}` - Actor lacks permission
* `{:error, :not_found}` - Sandbox not found

# `depth_of`

```elixir
@spec depth_of(Ecto.UUID.t()) :: non_neg_integer()
```

Depth of a project in the parent tree. Roots return 0, a direct child
sandbox returns 1, and so on. Bounded by `max_project_tree_depth/0`.

# `descendant_ids`

```elixir
@spec descendant_ids([Ecto.UUID.t()]) :: [Ecto.UUID.t()]
```

Returns a flat list of descendant project IDs for the given project IDs.
Convenience wrapper around `descendants_query/1`.

# `descendant_of?`

```elixir
@spec descendant_of?(
  Lightning.Projects.Project.t(),
  Lightning.Projects.Project.t(),
  Lightning.Projects.Project.t() | nil
) :: boolean()
```

Returns true if `child_project` is a descendant of `parent_project`.

Walks up the parent chain using preloaded `:parent` associations to determine
if `child_project` has `parent_project` anywhere in its ancestry.

## Parameters
- `child_project`: The project to check (must have `:parent` preloaded)
- `parent_project`: The potential parent/ancestor project
- `root_project`: Optional root project to use as stopping condition

## Examples
    iex> Projects.descendant_of?(sandbox, parent_project)
    true

    iex> Projects.descendant_of?(sibling, parent_project)
    false

# `descendants_query`

```elixir
@spec descendants_query([Ecto.UUID.t()]) :: Ecto.Query.t()
```

Returns a composable query that selects all descendant project IDs for the
given list of project IDs. Walks the `parent_id` tree downward using a
recursive CTE. The input IDs themselves are **not** included in the results.

# `display_name_within_access_root`

```elixir
@spec display_name_within_access_root(
  Lightning.Projects.Project.t(),
  Lightning.Projects.Project.t()
) ::
  String.t()
```

Display name from `access_root` down to `project`, joined by `/`.

# `export_project`

```elixir
@spec export_project(:yaml, Ecto.UUID.t(), [Ecto.UUID.t()] | nil, :v1 | :v2) ::
  {:ok, binary()} | {:error, binary()}
```

Exports a project as yaml.

The `format` is required and selects the serializer:
  * `:v1` — legacy Lightning format (`Lightning.ExportUtils`). Hard-wired
    for the provisioner API so external CLIs that consume
    `GET /api/provision/yaml` keep working.
  * `:v2` — portability spec format (`Lightning.Workflows.YamlFormat.V2`).
    Used by the in-app "Export project as YAML" download.

`snapshot_ids` may be `nil` (export current workflows) or a list of
snapshot ids (export those specific snapshots).

## Examples

    iex> export_project(:yaml, project_id, nil, :v2)
    {:ok, string}

Returns `{:error, message}` when two entities in the project would be written
under the same key in the spec. See
`Lightning.ExportUtils.DuplicateKeyError`.

# `get_project`

```elixir
@spec get_project(Ecto.UUID.t()) :: Lightning.Projects.Project.t() | nil
```

Fetches a project by id (root **or** sandbox) and preloads its direct `:parent`.

Returns `nil` if no project with the given id exists.

# `get_project!`

```elixir
@spec get_project!(Ecto.UUID.t()) :: Lightning.Projects.Project.t()
```

Fetches a project by id (root **or** sandbox) and preloads its direct `:parent`.

Raises `Ecto.NoResultsError` if no project with the given id exists.

# `get_project_credential`

# `get_project_for_run`

```elixir
@spec get_project_for_run(Lightning.Run.t()) :: Lightning.Projects.Project.t() | nil
```

Gets the project associated with a run.
Traverses Run → WorkOrder → Workflow → Project.

Returns nil if the run is not associated with a project.

## Examples

    iex> get_project_for_run(run)
    %Project{id: "...", env: "production", ...}

    iex> get_project_for_run(orphaned_run)
    nil

# `get_project_tree_for_user`

```elixir
@spec get_project_tree_for_user(Lightning.Accounts.User.t()) :: [
  Lightning.Projects.ProjectTreeItem.t()
]
```

Returns the user-visible project tree as a list of `ProjectTreeItem`s
ready for a hierarchical render (walk by grouping on `parent_id`).

The user's *access roots* are the topmost projects they can reach: every
project they hold a `project_users` row on (any depth), plus, for support
users, every workspace root flagged `allow_support_access`. A membership
on a deep sandbox without membership on its ancestors is therefore a
legitimate access root.

Descendants are filtered by the same per-project rule: each descendant
is included only when the user has a `project_users` row on it or is a
support user and that descendant carries `allow_support_access: true`.
Authority does not cascade from a parent project to its descendants.

`parent_id` on the returned items is the *visible* parent: `nil` at each
access root, and the nearest visible ancestor for descendants whose real
parent is hidden. The result is not a list of persistable records; see
`ProjectTreeItem`.

# `get_project_user`

```elixir
@spec get_project_user(Ecto.UUID.t()) :: Lightning.Projects.ProjectUser.t() | nil
```

# `get_project_user`

```elixir
@spec get_project_user(
  project :: Lightning.Projects.Project.t(),
  user :: Lightning.Accounts.User.t()
) ::
  Lightning.Projects.ProjectUser.t() | nil
@spec get_project_user(project_id :: binary(), user :: Lightning.Accounts.User.t()) ::
  Lightning.Projects.ProjectUser.t() | nil
```

# `get_project_user!`

Gets a single project_user.

Raises `Ecto.NoResultsError` if the ProjectUser does not exist.

## Examples

    iex> get_project_user!(123)
    %ProjectUser{}

    iex> get_project_user!(456)
    ** (Ecto.NoResultsError)

# `get_project_user_for_project`

```elixir
@spec get_project_user_for_project(term(), Lightning.Projects.Project.t(), keyword()) ::
  Lightning.Projects.ProjectUser.t() | nil
```

Gets a project user by id only when it belongs to `project`.

Returns `nil` for a malformed, missing, or cross-project id. Use this instead
of `get_project_user!/2` whenever the id comes from client input scoped to a
project in the URL, so an action can't reach a membership in another project.

# `get_project_user_role`

```elixir
@spec get_project_user_role(
  user :: Lightning.Accounts.User.t(),
  project :: Lightning.Projects.Project.t()
) :: atom() | nil
```

Returns the role of a user in a project.
Possible roles are :admin, :viewer, :editor, and :owner

## Examples

    iex> get_project_user_role(user, project)
    :admin

    iex> get_project_user_role(user, project)
    :viewer

    iex> get_project_user_role(user, project)
    :editor

    iex> get_project_user_role(user, project)
    :owner

# `get_project_users!`

Get all project users for a given project

# `get_project_with_users!`

Gets a single project with it's members via `project_users`.

Raises `Ecto.NoResultsError` if the Project does not exist.

## Examples

    iex> get_project!(123)
    %Project{}

    iex> get_project!(456)
    ** (Ecto.NoResultsError)

# `get_projects_for_user`

```elixir
@spec get_projects_for_user(user :: Lightning.Accounts.User.t()) :: [
  Lightning.Projects.Project.t()
]
```

Fetches projects for a given user from the database.

## Parameters
- user: The user struct for which projects are being queried.
- opts: Keyword list of options including :include for associations to preload and :order_by for sorting.

## Returns
- A list of projects associated with the user.

# `get_projects_overview`

# `invite_collaborators`

# `list_active_sandboxes_for_editing`

```elixir
@spec list_active_sandboxes_for_editing(Ecto.UUID.t(), String.t()) :: [
  {Lightning.Projects.Project.t(), Ecto.UUID.t() | nil}
]
```

Lists a parent project's active sandboxes for the "Edit in sandbox" picker.

Returns only the direct children of `parent_id` that are not scheduled for
deletion, sorted by `inserted_at` descending to match the "Created {relative}"
label the picker renders for each sandbox. Each sandbox preloads only its
owner `project_user` (and their user) for owner display, and resolves the
clone of the workflow named `workflow_name` so the caller can offer a direct
"join" target. The resolved workflow id is returned as `:joinable_workflow_id`,
or `nil` when the sandbox has no workflow with that name.

# `list_descendants`

```elixir
@spec list_descendants(Ecto.UUID.t()) :: [Lightning.Projects.Project.t()]
```

Returns every descendant project of `project_id`, ordered by name.

Unlike `list_workspace_projects/2`, the input is treated as the subtree
root: only its descendants are returned, not the absolute root of the
workspace.

# `list_project_admin_emails`

```elixir
@spec list_project_admin_emails(Ecto.UUID.t()) :: [String.t(), ...] | []
```

Lists emails of users with `:owner` or `:admin` roles in the project

# `list_project_credentials`

```elixir
@spec list_project_credentials(project :: Lightning.Projects.Project.t()) :: [
  Lightning.Projects.ProjectCredential.t()
]
```

# `list_project_files`

# `list_projects`

Returns the list of projects.

## Examples

    iex> list_projects()
    [%Project{}, ...]

# `list_projects_having_history_retention`

```elixir
@spec list_projects_having_history_retention() ::
  [] | [Lightning.Projects.Project.t(), ...]
```

Lists all projects that have history retention

# `list_sandboxes`

```elixir
@spec list_sandboxes(Ecto.UUID.t()) :: [Lightning.Projects.Project.t()]
```

Returns the *direct* sandboxes (children) of a parent project, ordered by `name` (ASC).

This is a flat view: only rows where `parent.id == child.parent_id` are returned.
If we later support arbitrarily deep nesting, switch this to a recursive CTE.

Intentionally **unfiltered** by `scheduled_deletion`. This function is the
recursive walker used by `Lightning.Extensions.ProjectHook.handle_delete_project/1`
to cascade hard-deletes through the subtree at purge time. Filtering would
skip scheduled descendants and (because the parent FK is `:nilify_all`) leave
them as orphan root projects in the database. User-facing surfaces should
use `list_workspace_projects/2`, which returns the full workspace and lets
the caller decide what to display: the sandboxes list shows scheduled rows
in a separate "Recently Deleted" section, while the picker filters them out
at the SQL level in `get_project_tree_for_user/1`'s active-descendants CTE.

# `list_workflows`

# `list_workspace_projects`

```elixir
@spec list_workspace_projects(Ecto.UUID.t(), keyword()) :: %{
  root: Lightning.Projects.Project.t(),
  descendants: [Lightning.Projects.Project.t()]
}
@spec list_workspace_projects(Lightning.Projects.Project.t(), keyword()) :: %{
  root: Lightning.Projects.Project.t(),
  descendants: [Lightning.Projects.Project.t()]
}
```

Returns all projects in a workspace hierarchy.

Returns a map with the root project and all its descendant sandboxes at any depth level.
Uses a recursive CTE to traverse the entire project tree from root to leaves.
Descendants are sorted as a flat list according to the specified options.

## Options
- `sort_by`: Field to sort by (`:name`, `:inserted_at`, `:updated_at`). Defaults to `:name`.
- `sort_order`: Sort direction (`:asc` or `:desc`). Defaults to `:asc`.

## Examples
    # Default sorting (name ascending)
    Projects.list_workspace_projects(project_id)

    # Sort by name descending
    Projects.list_workspace_projects(project_id, sort_by: :name, sort_order: :desc)

    # Sort by creation date
    Projects.list_workspace_projects(project_id, sort_by: :inserted_at, sort_order: :desc)

# `max_project_tree_depth`

```elixir
@spec max_project_tree_depth() :: pos_integer()
```

Maximum depth bound applied to every `parent_id` walk in this module's
recursive CTEs. Derived as `max_sandbox_nesting_depth() + 1` so the CTE
bound is always one hop above the deepest legitimate project. A real
cycle has no root and exhausts the buffer hop instead of looping until
Postgres' `statement_timeout` fires.

# `member_project_ids`

```elixir
@spec member_project_ids(Lightning.Accounts.User.t()) :: [Ecto.UUID.t()]
```

Returns the ids of every project the user holds a membership on, at any
depth. Unlike `get_projects_for_user/1` (top-level roots only), this includes
sandboxes, so it matches the membership check used by `:access_project`.

# `perform`

Perform, when called with %{"type" => "purge_deleted"}
will find projects that are ready for permanent deletion and purge them.

# `preload_ancestors`

```elixir
@spec preload_ancestors(Lightning.Projects.Project.t()) ::
  Lightning.Projects.Project.t()
```

Preloads the full ancestor chain on a project's `:parent` association,
so that `Project.display_name/1` can walk to the root.

Fetches the entire chain in a single recursive CTE query.

# `project_credentials_query`

# `project_dataclips_query`

# `project_jobs_query`

# `project_oauth_clients_query`

# `project_run_step_query`

# `project_runs_query`

# `project_steps_query`

# `project_triggers_query`

# `project_users_query`

```elixir
@spec project_users_query(atom() | %{:id =&gt; any(), optional(any()) =&gt; any()}) ::
  Ecto.Query.t()
```

# `project_workflows_query`

# `project_workorders_query`

# `projects_for_user_query`

```elixir
@spec projects_for_user_query(user :: Lightning.Accounts.User.t()) ::
  Ecto.Queryable.t()
```

Builds a query to retrieve projects associated with a user.

## Parameters
  - user: The user struct for which projects are being queried.
  - opts: Keyword list of options including :include for associations to preload and :order_by for sorting.

## Returns
  - An Ecto queryable struct to fetch projects.

# `promote_workflow`

```elixir
@spec promote_workflow(Lightning.Workflows.Workflow.t(), Lightning.Accounts.User.t()) ::
  {:ok, %{parent_project_id: Ecto.UUID.t(), workflow_id: Ecto.UUID.t() | nil}}
  | {:error, :not_a_sandbox | term()}
```

Promotes a workflow edited inside a sandbox back to its parent project.

Reuses `Sandboxes.merge/4` scoped to the single workflow, so siblings on the
parent pass through untouched. Authorization is the caller's, as it is there.
Archiving the sandbox is separate, so several workflows can be promoted from
one sandbox before it is retired.

Returns `{:ok, %{parent_project_id: id, workflow_id: id | nil}}`,
`{:error, :not_a_sandbox}`, or the merge's own error.

# `provision_editing_sandbox`

```elixir
@spec provision_editing_sandbox(
  Lightning.Projects.Project.t(),
  Lightning.Accounts.User.t(),
  String.t(),
  map()
) ::
  {:ok,
   %{
     sandbox: Lightning.Projects.Project.t(),
     workflow: Lightning.Workflows.Workflow.t(),
     starting_dataclip_id: Ecto.UUID.t() | nil
   }}
  | {:error, term()}
```

Provisions a sandbox from `parent` and returns the sandbox together with its
clone of `workflow_name`, so the "Edit in sandbox" flow lands the user on the
edited workflow.

The edited clone comes in disabled and `:draft`, exactly like every other
cloned workflow: the clone is deliberately NOT promoted to live. Taking it
live in the sandbox is what turns its triggers on, so a user can test
connections against dev systems.

# `provision_sandbox`

```elixir
@spec provision_sandbox(
  Lightning.Projects.Project.t(),
  Lightning.Accounts.User.t(),
  Lightning.Projects.Sandboxes.provision_attrs()
) :: {:ok, Lightning.Projects.Project.t()} | {:error, term()}
```

Creates a new sandbox project by cloning from a parent project.

## Parameters
* `parent` - Project to clone from
* `actor` - User creating the sandbox (needs `:owner` or `:admin` role on parent)
* `attrs` - Creation attributes (name, color, env, collaborators, dataclip_ids)

## Returns
* `{:ok, sandbox_project}` - Successfully created sandbox
* `{:error, :unauthorized}` - Actor lacks permission on parent
* `{:error, changeset}` - Validation or database error

See `Lightning.Projects.Sandboxes.provision/3` for detailed behavior.

# `remove_all_files_for`

```elixir
@spec remove_all_files_for(Lightning.Projects.Project.t()) ::
  :ok | {:error, [Lightning.Projects.File.t()]}
```

Removes every stored file belonging to a project, both the object in the
storage backend and the `project_files` row.

Used by the deletion path: `project_files.project_id` doesn't cascade, so
these have to go before the project itself can be deleted. Returns the files
that could not be removed, so the caller can stop rather than tear the
project down around an archive that is still sitting in storage.

# `root_id`

```elixir
@spec root_id(Lightning.Projects.Project.t() | Ecto.UUID.t()) :: Ecto.UUID.t() | nil
```

Returns the topmost ancestor (root) project id for the given project. For a
root project (`parent_id == nil`) returns its own id. Returns `nil` if the
project does not exist.

Used by the GitHub-sync guard to ensure no two projects sharing the same
ultimate root claim the same `(repo, branch)` pair.

# `root_of`

```elixir
@spec root_of(Lightning.Projects.Project.t()) :: Lightning.Projects.Project.t()
```

Returns the **root ancestor** of a project.

Uses `preload_ancestors/1` (one recursive CTE) and then walks the loaded
`:parent` chain in memory, so the cost is one round trip regardless of
how deep `project` sits in its workspace. The returned root carries
`parent: nil` (it has no parent in the database); intermediate ancestors
remain on the chain in case the caller wants them.

# `sandbox_name_exists?`

Checks if a sandbox with the given name exists under the parent project.

Returns `true` if a sandbox exists, `false` otherwise.
Optionally excludes a specific sandbox by ID (useful for edit operations).

# `save_dataclips?`

Should input or output dataclips be saved for runs in this project?

# `schedule_project_deletion`

Given a project, this function sets a scheduled deletion
date based on the PURGE_DELETED_AFTER_DAYS environment variable. If no ENV is
set, this date defaults to NOW but the automatic project purge cronjob will
never run. (Note that subsequent logins will be blocked for projects pending
deletion.)

# `scheduled_project_deletion_changes`

# `select_first_project_for_user`

```elixir
@spec select_first_project_for_user(user :: Lightning.Accounts.User.t()) ::
  Lightning.Projects.Project.t() | nil
```

# `set_notification_pref`

```elixir
@spec set_notification_pref(Lightning.Projects.ProjectUser.t(), atom(), term()) ::
  {:ok, Lightning.Projects.ProjectUser.t()}
  | {:error, Ecto.Changeset.t()}
  | :unchanged
```

Updates a single notification preference on a project user.

Returns `:unchanged` when the submitted value casts equal to the current
value, so callers can skip the success flash on a no-op submit.

# `subscribe`

# `update_project`

Updates a project.

## Examples

    iex> update_project(project, %{field: new_value})
    {:ok, %Project{}}

    iex> update_project(project, %{field: bad_value})
    {:error, %Ecto.Changeset{}}

# `update_project_with_users`

```elixir
@spec update_project_with_users(
  Lightning.Projects.Project.t(),
  map(),
  Lightning.Accounts.User.t(),
  boolean()
) :: {:ok, Lightning.Projects.Project.t()} | {:error, Ecto.Changeset.t()}
```

# `update_sandbox`

```elixir
@spec update_sandbox(
  Lightning.Projects.Project.t() | Ecto.UUID.t(),
  Lightning.Accounts.User.t(),
  map()
) ::
  {:ok, Lightning.Projects.Project.t()}
  | {:error, :unauthorized | :not_found | Ecto.Changeset.t()}
```

Updates a sandbox project's basic attributes (name, color, env).

## Parameters
* `sandbox` - Sandbox to update (project struct or ID string)
* `actor` - User performing update (needs `:owner` or `:admin` role on sandbox)
* `attrs` - Map with name, color, and/or env keys

## Returns
* `{:ok, updated_sandbox}` - Successfully updated
* `{:error, :unauthorized}` - Actor lacks permission
* `{:error, :not_found}` - Sandbox not found
* `{:error, changeset}` - Validation error

# `validate_for_deletion`

Returns an `%Ecto.Changeset{}` for changing the project scheduled_deletion.

## Examples

    iex> validate_for_deletion(project)
    %Ecto.Changeset{data: %Project{}}

# `visible_sandboxes`

```elixir
@spec visible_sandboxes([Lightning.Projects.Project.t()], Lightning.Accounts.User.t()) ::
  [
    Lightning.Projects.Project.t()
  ]
```

Returns the subset of `sandboxes` that `user` is allowed to see.

A sandbox is visible when the user has a `project_users` row on that
sandbox, or is a support user on a sandbox flagged
`allow_support_access`. Visibility does not cascade from a parent
project; each sandbox is an independent project with its own
membership list (seeded from the parent at provision time).

Assumes each `sandbox.project_users` is preloaded; raises
`ArgumentError` otherwise.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
