PhoenixKitProjects.Attachments (PhoenixKitProjects v0.26.0)

Copy Markdown View Source

Folder-scoped file attachments for a project, backed by core PhoenixKit.Modules.Storage — the workspace's per-resource-folder convention (phoenix_kit_staff.Attachments is the reference this mirrors): no module-owned table, no migration.

Each project owns a deterministic root folder project-<uuid>, resolved by name on every read (never cached on the project row, so renames or deletions in /admin/media can't strand a dangling uuid) and created lazily on first use. Core's [:name, :parent_uuid] unique index makes find-or-create race-safe. Files live in core phoenix_kit_files — a file is IN the folder either as its home (file.folder_uuid) or via a FolderLink. Removal follows core's non-destructive convention: soft-trash a sole-home file, promote a link on a shared one, drop the link on a linked-only one — never hard-delete a possibly-shared asset.

Parent folder

A host can nest a project's folder under one of its own — e.g. a project linked to a sub-order keeps its files inside that sub-order's folder — via two config hooks, both nil/unset by default (today's root-only behaviour, unchanged):

  • :attachments_parent_folder — {mod, fun} where fun(kind, actor_uuid, subject) (preferred) or fun(kind, actor_uuid) returns {:ok, parent_folder_uuid} or anything else for "no parent". subject is the bare %Project{} for read-only lookups (folder_uuid/2, render-safe: never creates a parent) and {:ensure, %Project{}} for ensure_folder/2, which may create the parent chain.
  • :attachments_folder_name — {mod, fun} where fun(resource, actor_uuid) returns {:ok, name} or anything else to fall back to the deterministic project-<uuid> name.

A folder is found by its {parent, name} pair — core records no owning resource on a folder — so the pair a host's hooks answer must be unique per project. Two projects that resolve to the same parent AND the same host name share one folder, and each Files page lists the other's files. A per-project parent (a sub-order's own folder) with a fixed name is fine; a shared container needs a name carrying something project-unique.

Resolution order (read-only, no writes): host-name-under-parent → deterministic-name-under-parent → deterministic-name-at-root → deterministic-name-anywhere — so a legacy root project-<uuid> folder, or one left under a since-unlinked parent, is found and reused rather than twinned once a parent hook is configured.

Summary

Functions

Links picked/uploaded files into the project folder: a homeless file gets this folder as home; a file homed elsewhere gains a FolderLink (idempotent per file).

Public download URL, nil-safe.

download_url/1 for a list of files in ONE file-instance read — %{file_uuid => url}, a file with no original instance absent. The Files page asked per row, twice (guard + href), on every render (the 2026-09-05 N+1 audit). Same URL rule as core's get_public_url/1: the storage manager's public URL when it has one, else a signed URL.

Find-or-create the project folder. Race-safe: a lost create re-resolves the winner via the unique index. The host-configured parent (via :attachments_parent_folder, subject {:ensure, project}) may build the parent chain; a legacy root project-<uuid> folder is found and reused rather than twinned.

Heroicon name for a file's type (template helper).

Deterministic root folder name for a project's files.

Resolves the project folder uuid WITHOUT creating it (render-safe).

Active files in the project folder (home or linked), newest first, capped.

Removes a file from the project folder. Home here + not linked elsewhere → soft-trash (recoverable in the media trash); home here + linked elsewhere → promote a link to home; linked-only here → drop the link.

Functions

attach_files(project_or_uuid, file_uuids, actor_uuid \\ nil)

@spec attach_files(
  binary() | PhoenixKitProjects.Schemas.Project.t(),
  [binary()],
  binary() | nil
) :: :ok

Links picked/uploaded files into the project folder: a homeless file gets this folder as home; a file homed elsewhere gains a FolderLink (idempotent per file).

download_url(file)

@spec download_url(PhoenixKit.Modules.Storage.File.t()) :: String.t() | nil

Public download URL, nil-safe.

download_urls(files)

@spec download_urls([PhoenixKit.Modules.Storage.File.t()]) :: %{
  required(binary()) => String.t()
}

download_url/1 for a list of files in ONE file-instance read — %{file_uuid => url}, a file with no original instance absent. The Files page asked per row, twice (guard + href), on every render (the 2026-09-05 N+1 audit). Same URL rule as core's get_public_url/1: the storage manager's public URL when it has one, else a signed URL.

ensure_folder(project_or_uuid, actor_uuid \\ nil)

@spec ensure_folder(binary() | PhoenixKitProjects.Schemas.Project.t(), binary() | nil) ::
  {:ok, binary()} | {:error, term()}

Find-or-create the project folder. Race-safe: a lost create re-resolves the winner via the unique index. The host-configured parent (via :attachments_parent_folder, subject {:ensure, project}) may build the parent chain; a legacy root project-<uuid> folder is found and reused rather than twinned.

file_icon(arg1)

@spec file_icon(map()) :: String.t()

Heroicon name for a file's type (template helper).

folder_name(project_uuid)

@spec folder_name(binary()) :: binary()

Deterministic root folder name for a project's files.

folder_uuid(project_or_uuid, actor_uuid \\ nil)

@spec folder_uuid(binary() | PhoenixKitProjects.Schemas.Project.t(), binary() | nil) ::
  binary() | nil

Resolves the project folder uuid WITHOUT creating it (render-safe).

list_files(project_or_uuid, actor_uuid \\ nil)

Active files in the project folder (home or linked), newest first, capped.

remove_file(project_or_uuid, file_uuid, actor_uuid \\ nil)

@spec remove_file(
  binary() | PhoenixKitProjects.Schemas.Project.t(),
  binary(),
  binary() | nil
) ::
  :ok | {:error, term()}

Removes a file from the project folder. Home here + not linked elsewhere → soft-trash (recoverable in the media trash); home here + linked elsewhere → promote a link to home; linked-only here → drop the link.