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}wherefun(kind, actor_uuid, subject)(preferred) orfun(kind, actor_uuid)returns{:ok, parent_folder_uuid}or anything else for "no parent".subjectis the bare%Project{}for read-only lookups (folder_uuid/2, render-safe: never creates a parent) and{:ensure, %Project{}}forensure_folder/2, which may create the parent chain.:attachments_folder_name—{mod, fun}wherefun(resource, actor_uuid)returns{:ok, name}or anything else to fall back to the deterministicproject-<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
@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).
@spec download_url(PhoenixKit.Modules.Storage.File.t()) :: String.t() | nil
Public download URL, nil-safe.
@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.
@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.
Heroicon name for a file's type (template helper).
Deterministic root folder name for a project's files.
@spec folder_uuid(binary() | PhoenixKitProjects.Schemas.Project.t(), binary() | nil) :: binary() | nil
Resolves the project folder uuid WITHOUT creating it (render-safe).
@spec list_files(binary() | PhoenixKitProjects.Schemas.Project.t(), binary() | nil) :: [ PhoenixKit.Modules.Storage.File.t() ]
Active files in the project folder (home or linked), newest first, capped.
@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.