Folder-scoped media attachments for a CRM contact, backed by core
PhoenixKit.Modules.Storage (the same per-resource-folder convention
phoenix_kit_staff/phoenix_kit_catalogue use — no module-owned table, no
migration).
Each contact owns a deterministic root folder crm-contact-<uuid> for generic
files, with a nested Images subfolder for images — all of a contact's
files in one folder, images in a folder inside it. Folders are resolved by
name on every read (never cached on Contact, so renaming/deleting the
folder in /admin/media can't strand a dangling uuid) and created lazily on
first upload; the core [:name, :parent_uuid] unique index makes
find-or-create race-safe.
Files live in core phoenix_kit_files under the folder; uploading/browsing is
done by MediaSelectorModal (scoped to the folder), so this module only
resolves folders, lists their files, (un)links picked files, and removes them
— soft-trash a sole-owner file, unlink a shared one. It never hard-deletes a
possibly-shared asset.
Summary
Types
Which CRM record a folder belongs to.
Functions
Ensures file_uuid is attached to folder_uuid: a no-op if already home
there (the modal's scoped uploads land here directly); adopts an orphan file
as home; otherwise adds a FolderLink so a file picked from elsewhere appears
here without being moved from its owner.
Whether file_uuid is one of the record's own Images-folder image files
(home or linked, excluding trashed) — the authorization basis for set_avatar/3.
The record's avatar File struct, or nil if unset / missing / trashed.
Thumbnail URL for the record's avatar (or nil).
The record's avatar file uuid (from metadata), or nil.
Clears the record's avatar pointer.
Removes a file from folder_uuid. Home here and not linked elsewhere →
soft-trash (recoverable in the media trash). Home here but also linked
elsewhere → promote a link to home. Here only via a FolderLink → drop the
link. Never hard-deletes a shared asset.
Public download URL for a file (nil-safe).
Find-or-create the folder for kind, returning {:ok, uuid} or
{:error, reason}. Race-safe: a lost create (unique [:name, :parent_uuid])
re-resolves the winner. Call when an action needs the folder to exist (opening
the picker / handling a selection).
Find-or-create an interaction's attachment folder.
Heroicon name for a file based on its Storage type / mime.
Resolves the folder uuid for kind (:files → root, :images → the nested
Images subfolder) without creating it. Returns the uuid or nil (used
on render so viewing a tab doesn't spawn empty folders).
Human-readable byte count. Nil-safe.
Fetch a File struct by uuid (nil-safe), for the composer's staged list.
Whether the file with this uuid is an image (by Storage file_type).
Deterministic root folder name for an interaction's attachments.
Resolve an interaction's attachment folder uuid (no create), or nil.
Files attached to folder_uuid (home-folder files plus those linked in via
FolderLink), newest first, excluding trashed. :only narrows by type:
:images (file_type == "image"), :non_images, or :all (default).
Defensive — keeps a tab showing only its own kind even if a stray file landed
in the folder.
Files for many interactions at once → %{interaction_uuid => [File.t()]} (only
interactions that have files appear). Two queries total (folders, then files);
used to render the timeline without an N+1. Compose-time uploads land home in
the interaction folder, so home-folder files are sufficient (no FolderLinks).
Files attached to an interaction (newest first, excluding trashed).
Purge an interaction's attachment folder subtree (best-effort).
Permanently purges a record's media — deletes the root folder and its whole
subtree (the nested Images folder + every file) via core's cascading
delete_folder_completely/1. Best-effort: logs and returns :ok on any
failure so it never blocks a deletion. Call only on a permanent delete
(soft-trash keeps the files).
Deterministic root folder name for a record's files (crm-<resource>-<uuid>).
Points the record's avatar at file_uuid (server-owned metadata write).
Thumbnail URL for an image file, falling back to the original (nil-safe).
Types
Functions
Ensures file_uuid is attached to folder_uuid: a no-op if already home
there (the modal's scoped uploads land here directly); adopts an orphan file
as home; otherwise adds a FolderLink so a file picked from elsewhere appears
here without being moved from its owner.
Whether file_uuid is one of the record's own Images-folder image files
(home or linked, excluding trashed) — the authorization basis for set_avatar/3.
@spec avatar_file(struct()) :: PhoenixKit.Modules.Storage.File.t() | nil
The record's avatar File struct, or nil if unset / missing / trashed.
Thumbnail URL for the record's avatar (or nil).
The record's avatar file uuid (from metadata), or nil.
Clears the record's avatar pointer.
Removes a file from folder_uuid. Home here and not linked elsewhere →
soft-trash (recoverable in the media trash). Home here but also linked
elsewhere → promote a link to home. Here only via a FolderLink → drop the
link. Never hard-deletes a shared asset.
Public download URL for a file (nil-safe).
@spec ensure_folder(resource(), binary(), :files | :images, binary() | nil) :: {:ok, binary()} | {:error, term()}
Find-or-create the folder for kind, returning {:ok, uuid} or
{:error, reason}. Race-safe: a lost create (unique [:name, :parent_uuid])
re-resolves the winner. Call when an action needs the folder to exist (opening
the picker / handling a selection).
Find-or-create an interaction's attachment folder.
Heroicon name for a file based on its Storage type / mime.
Resolves the folder uuid for kind (:files → root, :images → the nested
Images subfolder) without creating it. Returns the uuid or nil (used
on render so viewing a tab doesn't spawn empty folders).
Human-readable byte count. Nil-safe.
@spec get_file(binary()) :: PhoenixKit.Modules.Storage.File.t() | nil
Fetch a File struct by uuid (nil-safe), for the composer's staged list.
Whether the file with this uuid is an image (by Storage file_type).
Deterministic root folder name for an interaction's attachments.
Resolve an interaction's attachment folder uuid (no create), or nil.
@spec list_files( binary() | nil, keyword() ) :: [PhoenixKit.Modules.Storage.File.t()]
Files attached to folder_uuid (home-folder files plus those linked in via
FolderLink), newest first, excluding trashed. :only narrows by type:
:images (file_type == "image"), :non_images, or :all (default).
Defensive — keeps a tab showing only its own kind even if a stray file landed
in the folder.
@spec list_files_by_interaction([binary()]) :: %{ required(binary()) => [PhoenixKit.Modules.Storage.File.t()] }
Files for many interactions at once → %{interaction_uuid => [File.t()]} (only
interactions that have files appear). Two queries total (folders, then files);
used to render the timeline without an N+1. Compose-time uploads land home in
the interaction folder, so home-folder files are sufficient (no FolderLinks).
@spec list_interaction_files(binary()) :: [PhoenixKit.Modules.Storage.File.t()]
Files attached to an interaction (newest first, excluding trashed).
@spec purge_interaction_media(binary()) :: :ok
Purge an interaction's attachment folder subtree (best-effort).
Permanently purges a record's media — deletes the root folder and its whole
subtree (the nested Images folder + every file) via core's cascading
delete_folder_completely/1. Best-effort: logs and returns :ok on any
failure so it never blocks a deletion. Call only on a permanent delete
(soft-trash keeps the files).
Deterministic root folder name for a record's files (crm-<resource>-<uuid>).
Points the record's avatar at file_uuid (server-owned metadata write).
Authorizes the pointer: file_uuid must be an image that actually lives in (or
is linked into) this record's Images folder — a forged event can't point
the avatar at an arbitrary file elsewhere in storage. Refuses a trashed record
({:error, :record_trashed}) and a non-candidate file
({:error, :not_record_image}); clearing is unguarded.
Thumbnail URL for an image file, falling back to the original (nil-safe).