Packages

phoenix_kit

1.7.98
1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

221 Versions

Jump to

Compare versions

24 files changed
+3489 additions
-2328 deletions
  @@ -1,3 +1,18 @@
1 + ## 1.7.98 - 2026-04-16
2 +
3 + ### Added
4 + - V99 migration: `trashed_at` column on `phoenix_kit_files` with partial index for soft-delete (PR #497)
5 + - Media trash bucket: soft-delete files with restore/empty/permanent-delete actions and sidebar count badge
6 + - `PhoenixKit.Modules.Storage.Workers.PruneTrashJob` — daily Oban cron (3 AM) that permanently deletes files older than `trash_retention_days` (default 30)
7 + - Drag-drop upload: drop device files directly onto the folder content area (`FolderDropUpload` JS hook)
8 + - URL-param hydration on first mount so reloads don't flash the root view
9 +
10 + ### Fixed
11 + - Scope guard on `restore_selected` in MediaBrowser — a scoped embed could previously restore files outside its scope via a crafted `toggle_select` payload
12 + - Trash view, permanent-delete, and `empty_trash` now respect `scope_folder_id` via recursive CTE
13 + - `list_files/1` excludes trashed files
14 + - Breadcrumb and search bar moved inside card body so padding matches grid/list content
15 +
1 16 ## 1.7.97 - 2026-04-15
2 17
3 18 ### Added
  @@ -1,6 +1,6 @@
1 1 {<<"links">>,[{<<"GitHub">>,<<"https://github.com/BeamLabEU/phoenix_kit">>}]}.
2 2 {<<"name">>,<<"phoenix_kit">>}.
3 - {<<"version">>,<<"1.7.97">>}.
3 + {<<"version">>,<<"1.7.98">>}.
4 4 {<<"description">>,
5 5 <<65,32,102,111,117,110,100,97,116,105,111,110,32,102,111,114,32,98,117,105,
6 6 108,100,105,110,103,32,69,108,105,120,105,114,32,80,104,111,101,110,105,
  @@ -83,6 +83,7 @@
83 83 <<"lib/modules/storage/workers/process_file_job.ex">>,
84 84 <<"lib/modules/storage/workers/delete_orphaned_file_job.ex">>,
85 85 <<"lib/modules/storage/workers/test_job.ex">>,
86 + <<"lib/modules/storage/workers/prune_trash_job.ex">>,
86 87 <<"lib/modules/storage/workers/sync_files_job.ex">>,
87 88 <<"lib/modules/storage/services">>,
88 89 <<"lib/modules/storage/services/pdf_processor.ex">>,
  @@ -171,6 +172,7 @@
171 172 <<"lib/phoenix_kit_web/components/invitation_banner.ex">>,
172 173 <<"lib/phoenix_kit_web/components/admin_nav.ex">>,
173 174 <<"lib/phoenix_kit_web/components/oauth_buttons.ex">>,
175 + <<"lib/phoenix_kit_web/components/media_browser.ex">>,
174 176 <<"lib/phoenix_kit_web/components/multilang_form.ex">>,
175 177 <<"lib/phoenix_kit_web/components/layouts.ex">>,
176 178 <<"lib/phoenix_kit_web/components/core">>,
  @@ -234,6 +236,7 @@
234 236 <<"lib/phoenix_kit_web/components/dashboard/context_selector.ex">>,
235 237 <<"lib/phoenix_kit_web/components/user_dashboard_nav.ex">>,
236 238 <<"lib/phoenix_kit_web/components/layout_wrapper.ex">>,
239 + <<"lib/phoenix_kit_web/components/media_browser.html.heex">>,
237 240 <<"lib/phoenix_kit_web/components/language_switcher.ex">>,
238 241 <<"lib/phoenix_kit_web/components/layouts">>,
239 242 <<"lib/phoenix_kit_web/components/layouts/admin.html.heex">>,
  @@ -482,6 +485,7 @@
482 485 <<"lib/phoenix_kit/migrations/postgres/v98.ex">>,
483 486 <<"lib/phoenix_kit/migrations/postgres/v30.ex">>,
484 487 <<"lib/phoenix_kit/migrations/postgres/v31.ex">>,
488 + <<"lib/phoenix_kit/migrations/postgres/v99.ex">>,
485 489 <<"lib/phoenix_kit/migrations/postgres/v41.ex">>,
486 490 <<"lib/phoenix_kit/migrations/postgres/v56.ex">>,
487 491 <<"lib/phoenix_kit/migrations/postgres/v09.ex">>,
  @@ -1,6 +1,6 @@
1 1 # Languages Module
2 2
3 - The PhoenixKit Languages module provides multi-language support with a two-tier locale system (base codes for URLs, full dialects for translations). It provides a unified language configuration used by both the public-facing language switcher and the admin panel.
3 + The PhoenixKit Languages module provides multi-language support with a two-tier locale system (base codes for URLs, full dialects for translations). It provides a unified language configuration used across the whole app: the public-facing language switcher, the pre-login language dropdown on the sign-in page, and the Language section inside the admin panel's user (avatar) menu. The admin header no longer has a separate globe switcher — admins change locale from the user avatar menu.
4 4
5 5 ## Quick Links
  @@ -17,6 +17,7 @@ defmodule PhoenixKit.Modules.Storage.File do
17 17 - `processing` - File is being processed (variants being generated)
18 18 - `active` - File is ready and available
19 19 - `failed` - Processing failed
20 + - `trashed` - File is in trash, pending restoration or permanent deletion
20 21
21 22 ## Fields
22 23
  @@ -106,6 +107,7 @@ defmodule PhoenixKit.Modules.Storage.File do
106 107 height: integer() | nil,
107 108 duration: integer() | nil,
108 109 status: String.t(),
110 + trashed_at: DateTime.t() | nil,
109 111 metadata: map() | nil,
110 112 user_uuid: UUIDv7.t(),
111 113 folder_uuid: UUIDv7.t() | nil,
  @@ -130,6 +132,7 @@ defmodule PhoenixKit.Modules.Storage.File do
130 132 field :height, :integer
131 133 field :duration, :integer
132 134 field :status, :string, default: "processing"
135 + field :trashed_at, :utc_datetime
133 136 field :metadata, :map
134 137
135 138 belongs_to :user, PhoenixKit.Users.Auth.User,
  @@ -187,6 +190,7 @@ defmodule PhoenixKit.Modules.Storage.File do
187 190 :height,
188 191 :duration,
189 192 :status,
193 + :trashed_at,
190 194 :metadata,
191 195 :user_uuid,
192 196 :folder_uuid
  @@ -203,7 +207,7 @@ defmodule PhoenixKit.Modules.Storage.File do
203 207 :user_uuid
204 208 ])
205 209 |> validate_inclusion(:file_type, ["image", "video", "document", "archive"])
206 - |> validate_inclusion(:status, ["processing", "active", "failed"])
210 + |> validate_inclusion(:status, ["processing", "active", "failed", "trashed"])
207 211 |> validate_number(:size, greater_than: 0)
208 212 |> validate_number(:width, greater_than: 0)
209 213 |> validate_number(:height, greater_than: 0)
  @@ -884,15 +884,40 @@ defmodule PhoenixKit.Modules.Storage do
884 884 end)
885 885 end
886 886
887 - @doc "Lists folders within a parent folder (nil = root)."
888 - def list_folders(parent_uuid \\ nil)
887 + @doc """
888 + Returns folder tree rooted at scope_folder_id (exclusive of scope itself).
889 + For nil scope, returns the real-root tree.
890 + """
891 + def list_folder_tree(scope_folder_id \\ nil) do
892 + all_folders = list_all_folders()
893 + by_parent = Enum.group_by(all_folders, & &1.parent_uuid)
889 894
890 - def list_folders(nil) do
895 + if scope_folder_id do
896 + build_tree_nodes(by_parent, scope_folder_id)
897 + else
898 + build_tree_nodes(by_parent, nil)
899 + end
900 + end
901 +
902 + @doc """
903 + Lists folders within a parent folder (nil = root).
904 +
905 + When parent_uuid is nil and scope_folder_id is set, returns children of
906 + scope_folder_id instead of real root.
907 + """
908 + def list_folders(parent_uuid \\ nil, scope_folder_id \\ nil)
909 +
910 + def list_folders(nil, nil) do
891 911 from(f in Folder, where: is_nil(f.parent_uuid), order_by: [asc: f.name])
892 912 |> repo().all()
893 913 end
894 914
895 - def list_folders(parent_uuid) do
915 + def list_folders(nil, scope_folder_id) do
916 + from(f in Folder, where: f.parent_uuid == ^scope_folder_id, order_by: [asc: f.name])
917 + |> repo().all()
918 + end
919 +
920 + def list_folders(parent_uuid, _scope_folder_id) do
896 921 from(f in Folder, where: f.parent_uuid == ^parent_uuid, order_by: [asc: f.name])
897 922 |> repo().all()
898 923 end
  @@ -901,15 +926,46 @@ defmodule PhoenixKit.Modules.Storage do
901 926 def get_folder(nil), do: nil
902 927 def get_folder(uuid), do: repo().get(Folder, uuid)
903 928
904 - @doc "Creates a new folder."
905 - def create_folder(attrs) do
929 + @doc """
930 + Creates a new folder.
931 +
932 + When scope_folder_id is set:
933 + - If attrs.parent_uuid is outside scope, returns `{:error, :out_of_scope}`.
934 + - If attrs.parent_uuid is nil, rewrites to scope_folder_id (new folder at scope root).
935 + """
936 + def create_folder(attrs, scope_folder_id \\ nil)
937 +
938 + def create_folder(attrs, nil) do
906 939 %Folder{}
907 940 |> Folder.changeset(attrs)
908 941 |> repo().insert()
909 942 end
910 943
911 - @doc "Updates a folder (rename, move). Returns `{:error, :cycle}` if the move would create a circular reference."
912 - def update_folder(%Folder{} = folder, attrs) do
944 + def create_folder(attrs, scope_folder_id) do
945 + parent_uuid = attrs[:parent_uuid] || attrs["parent_uuid"]
946 +
947 + cond do
948 + is_nil(parent_uuid) ->
949 + attrs = Map.put(attrs, :parent_uuid, scope_folder_id)
950 + %Folder{} |> Folder.changeset(attrs) |> repo().insert()
951 +
952 + within_scope?(parent_uuid, scope_folder_id) ->
953 + %Folder{} |> Folder.changeset(attrs) |> repo().insert()
954 +
955 + true ->
956 + {:error, :out_of_scope}
957 + end
958 + end
959 +
960 + @doc """
961 + Updates a folder (rename, color change, move).
962 +
963 + Returns `{:error, :cycle}` if the move would create a circular reference.
964 + Returns `{:error, :out_of_scope}` if the folder or new parent is outside scope.
965 + """
966 + def update_folder(folder, attrs, scope_folder_id \\ nil)
967 +
968 + def update_folder(%Folder{} = folder, attrs, nil) do
913 969 new_parent = attrs[:parent_uuid] || attrs["parent_uuid"]
914 970
915 971 if new_parent && new_parent != folder.parent_uuid && ancestor_of?(folder.uuid, new_parent) do
  @@ -921,13 +977,49 @@ defmodule PhoenixKit.Modules.Storage do
921 977 end
922 978 end
923 979
980 + def update_folder(%Folder{} = folder, attrs, scope_folder_id) do
981 + if within_scope?(folder.uuid, scope_folder_id) do
982 + new_parent = attrs[:parent_uuid] || attrs["parent_uuid"]
983 +
984 + cond do
985 + new_parent && not within_scope?(new_parent, scope_folder_id) ->
986 + {:error, :out_of_scope}
987 +
988 + new_parent && new_parent != folder.parent_uuid && ancestor_of?(folder.uuid, new_parent) ->
989 + {:error, :cycle}
990 +
991 + true ->
992 + folder
993 + |> Folder.changeset(attrs)
994 + |> repo().update()
995 + end
996 + else
997 + {:error, :out_of_scope}
998 + end
999 + end
1000 +
924 1001 @doc """
925 1002 Deletes a folder.
926 1003
927 1004 Moves child folders and home files to the deleted folder's parent.
928 1005 Folder links are cascade-deleted by the database FK.
1006 + Returns `{:error, :out_of_scope}` if the folder is outside scope.
929 1007 """
930 - def delete_folder(%Folder{} = folder) do
1008 + def delete_folder(folder, scope_folder_id \\ nil)
1009 +
1010 + def delete_folder(%Folder{} = folder, scope_folder_id) when not is_nil(scope_folder_id) do
1011 + if within_scope?(folder.uuid, scope_folder_id) do
1012 + do_delete_folder(folder)
1013 + else
1014 + {:error, :out_of_scope}
1015 + end
1016 + end
1017 +
1018 + def delete_folder(%Folder{} = folder, nil) do
1019 + do_delete_folder(folder)
1020 + end
1021 +
1022 + defp do_delete_folder(%Folder{} = folder) do
931 1023 repo().transaction(fn ->
932 1024 # Move child folders to parent
933 1025 from(f in Folder, where: f.parent_uuid == ^folder.uuid)
  @@ -945,16 +1037,35 @@ defmodule PhoenixKit.Modules.Storage do
945 1037 end)
946 1038 end
947 1039
948 - @doc "Returns the ancestor chain from root to the given folder (for breadcrumbs)."
949 - def folder_breadcrumbs(folder_uuid), do: folder_breadcrumbs(folder_uuid, 50)
1040 + @doc """
1041 + Returns the ancestor chain from root to the given folder (for breadcrumbs).
950 1042
951 - defp folder_breadcrumbs(nil, _limit), do: []
952 - defp folder_breadcrumbs(_uuid, 0), do: []
1043 + When scope_folder_id is set, the chain stops before scope (scope itself not included —
1044 + it is the virtual root).
1045 + """
1046 + def folder_breadcrumbs(folder_uuid, scope_folder_id \\ nil)
953 1047
954 - defp folder_breadcrumbs(folder_uuid, limit) do
1048 + def folder_breadcrumbs(folder_uuid, nil) do
1049 + do_folder_breadcrumbs(folder_uuid, 50)
1050 + end
1051 +
1052 + def folder_breadcrumbs(folder_uuid, scope_folder_id) do
1053 + folder_uuid
1054 + |> do_folder_breadcrumbs(50)
1055 + |> Enum.drop_while(fn f -> f.uuid != scope_folder_id end)
1056 + |> case do
1057 + [] -> []
1058 + [_ | rest] -> rest
1059 + end
1060 + end
1061 +
1062 + defp do_folder_breadcrumbs(nil, _limit), do: []
1063 + defp do_folder_breadcrumbs(_uuid, 0), do: []
1064 +
1065 + defp do_folder_breadcrumbs(folder_uuid, limit) do
955 1066 case get_folder(folder_uuid) do
956 1067 nil -> []
957 - folder -> folder_breadcrumbs(folder.parent_uuid, limit - 1) ++ [folder]
1068 + folder -> do_folder_breadcrumbs(folder.parent_uuid, limit - 1) ++ [folder]
958 1069 end
959 1070 end
960 1071
  @@ -976,6 +1087,19 @@ defmodule PhoenixKit.Modules.Storage do
976 1087 end
977 1088 end
978 1089
1090 + @doc """
1091 + Returns true if folder_uuid is within the given scope.
1092 +
1093 + - When scope_folder_id is nil, always returns true (no scope restriction).
1094 + - When folder_uuid equals scope_folder_id, returns true (scope is the virtual root).
1095 + - When scope_folder_id is an ancestor of folder_uuid, returns true (folder is a descendant).
1096 + - Returns false otherwise, including when folder_uuid is nil and scope is set
1097 + (real root is outside any non-nil scope).
1098 + """
1099 + def within_scope?(_folder_uuid, nil), do: true
1100 + def within_scope?(folder_uuid, scope_folder_id) when folder_uuid == scope_folder_id, do: true
1101 + def within_scope?(folder_uuid, scope_folder_id), do: ancestor_of?(scope_folder_id, folder_uuid)
1102 +
979 1103 @doc "Counts files in a folder (home files + linked files)."
980 1104 def count_folder_contents(nil) do
981 1105 home =
  @@ -1000,26 +1124,182 @@ defmodule PhoenixKit.Modules.Storage do
1000 1124 (home || 0) + (links || 0)
1001 1125 end
1002 1126
1127 + @doc """
1128 + Lists files within the given scope with optional folder filter, search, and pagination.
1129 +
1130 + ## Options
1131 + - `:folder_uuid` — specific folder within scope; returns `{:error, :out_of_scope}` if outside.
1132 + - `:search` — ilike search on original_file_name; restricted to scope descendants when scope set.
1133 + - `:include_orphaned` — boolean (default false); only meaningful when scope is nil.
1134 + When true, returns only files with folder_uuid IS NULL.
1135 + `include_orphaned: true` is ignored when `scope_folder_id` is non-nil (orphans are always outside any scope).
1136 + - `:page` — page number (default 1).
1137 + - `:per_page` — page size (default 20).
1138 +
1139 + ## Returns
1140 + `{files, total_count}` or `{:error, :out_of_scope}`.
1141 +
1142 + When `scope_folder_id == nil` and no `folder_uuid` is specified, ALL files are returned (not
1143 + just orphans). To fetch orphans only at real root, pass `include_orphaned: true` AND use a
1144 + dedicated orphan-only branch. Task 4 callers must preserve the current `/admin/media` behavior
1145 + (list orphans only when `filter_orphaned` is on) via a separate code path.
1146 + """
1147 + def list_files_in_scope(scope_folder_id, opts \\ []) do
1148 + folder_uuid = opts[:folder_uuid]
1149 + search = opts[:search]
1150 + include_orphaned = Keyword.get(opts, :include_orphaned, false)
1151 + page = Keyword.get(opts, :page, 1)
1152 + per_page = Keyword.get(opts, :per_page, 20)
1153 +
1154 + if not is_nil(folder_uuid) and not is_nil(scope_folder_id) and
1155 + not within_scope?(folder_uuid, scope_folder_id) do
1156 + {:error, :out_of_scope}
1157 + else
1158 + query =
1159 + build_scope_file_query(scope_folder_id, folder_uuid, search, include_orphaned)
1160 + |> where([f], f.status != "trashed")
1161 +
1162 + total = repo().aggregate(query, :count, :uuid)
1163 +
1164 + files =
1165 + query
1166 + |> order_by([f], desc: f.inserted_at)
1167 + |> offset(^((page - 1) * per_page))
1168 + |> limit(^per_page)
1169 + |> repo().all()
1170 +
1171 + {files, total}
1172 + end
1173 + end
1174 +
1175 + defp build_scope_file_query(nil, nil, nil, false) do
1176 + from(f in PhoenixKit.Modules.Storage.File)
1177 + end
1178 +
1179 + defp build_scope_file_query(nil, nil, nil, true) do
1180 + from(f in PhoenixKit.Modules.Storage.File, where: is_nil(f.folder_uuid))
1181 + end
1182 +
1183 + defp build_scope_file_query(nil, folder_uuid, search, _orphaned) when not is_nil(folder_uuid) do
1184 + from(f in PhoenixKit.Modules.Storage.File, where: f.folder_uuid == ^folder_uuid)
1185 + |> apply_file_search(search)
1186 + end
1187 +
1188 + defp build_scope_file_query(nil, nil, search, _orphaned) when not is_nil(search) do
1189 + from(f in PhoenixKit.Modules.Storage.File)
1190 + |> apply_file_search(search)
1191 + end
1192 +
1193 + defp build_scope_file_query(scope_folder_id, folder_uuid, search, _orphaned) do
1194 + if folder_uuid do
1195 + # Specific folder already validated within scope — no CTE needed
1196 + from(f in PhoenixKit.Modules.Storage.File, where: f.folder_uuid == ^folder_uuid)
1197 + else
1198 + # Use recursive CTE to find all descendant folders of scope (including scope itself)
1199 + cte_base =
1200 + from(f in Folder,
1201 + where: f.uuid == ^scope_folder_id,
1202 + select: %{uuid: f.uuid}
1203 + )
1204 +
1205 + cte_recursive =
1206 + from(f in Folder,
1207 + join: d in "scope_descendants",
1208 + on: f.parent_uuid == d.uuid,
1209 + select: %{uuid: f.uuid}
1210 + )
1211 +
1212 + cte = union_all(cte_base, ^cte_recursive)
1213 +
1214 + from(f in PhoenixKit.Modules.Storage.File,
1215 + join: d in "scope_descendants",
1216 + on: f.folder_uuid == d.uuid,
1217 + select: f
1218 + )
1219 + |> recursive_ctes(true)
1220 + |> with_cte("scope_descendants", as: ^cte)
1221 + end
1222 + |> apply_file_search(search)
1223 + end
1224 +
1225 + defp apply_file_search(query, nil), do: query
1226 + defp apply_file_search(query, ""), do: query
1227 +
1228 + defp apply_file_search(query, search) do
1229 + term = "%#{search}%"
1230 +
1231 + where(
1232 + query,
1233 + [f],
1234 + ilike(f.original_file_name, ^term) or
1235 + fragment("CAST(? AS TEXT) ILIKE ?", f.uuid, ^term)
1236 + )
1237 + end
1238 +
1003 1239 @doc "Moves a file's home folder."
1004 - def move_file_to_folder(file_uuid, folder_uuid) do
1240 + def move_file_to_folder(file_uuid, target_folder_uuid, scope_folder_id \\ nil)
1241 +
1242 + def move_file_to_folder(file_uuid, target_folder_uuid, nil) do
1005 1243 file = repo().get(PhoenixKit.Modules.Storage.File, file_uuid)
1006 1244
1007 1245 if file do
1008 1246 file
1009 - |> Ecto.Changeset.change(%{folder_uuid: folder_uuid})
1247 + |> Ecto.Changeset.change(%{folder_uuid: target_folder_uuid})
1010 1248 |> repo().update()
1011 1249 else
1012 1250 {:error, :not_found}
1013 1251 end
1014 1252 end
1015 1253
1254 + def move_file_to_folder(file_uuid, target_folder_uuid, scope_folder_id) do
1255 + file = repo().get(PhoenixKit.Modules.Storage.File, file_uuid)
1256 +
1257 + cond do
1258 + is_nil(file) ->
1259 + {:error, :not_found}
1260 +
1261 + not within_scope?(file.folder_uuid, scope_folder_id) ->
1262 + {:error, :out_of_scope}
1263 +
1264 + not within_scope?(target_folder_uuid, scope_folder_id) ->
1265 + {:error, :out_of_scope}
1266 +
1267 + true ->
1268 + file
1269 + |> Ecto.Changeset.change(%{folder_uuid: target_folder_uuid})
1270 + |> repo().update()
1271 + end
1272 + end
1273 +
1016 1274 @doc "Creates a link (shortcut) of a file in a folder."
1017 - def create_folder_link(folder_uuid, file_uuid) do
1275 + def create_folder_link(folder_uuid, file_uuid, scope_folder_id \\ nil)
1276 +
1277 + def create_folder_link(folder_uuid, file_uuid, nil) do
1018 1278 %FolderLink{}
1019 1279 |> FolderLink.changeset(%{folder_uuid: folder_uuid, file_uuid: file_uuid})
1020 1280 |> repo().insert()
1021 1281 end
1022 1282
1283 + def create_folder_link(folder_uuid, file_uuid, scope_folder_id) do
1284 + file = repo().get(PhoenixKit.Modules.Storage.File, file_uuid)
1285 +
1286 + cond do
1287 + not within_scope?(folder_uuid, scope_folder_id) ->
1288 + {:error, :out_of_scope}
1289 +
1290 + is_nil(file) ->
1291 + {:error, :not_found}
1292 +
1293 + not within_scope?(file.folder_uuid, scope_folder_id) ->
1294 + {:error, :out_of_scope}
1295 +
1296 + true ->
1297 + %FolderLink{}
1298 + |> FolderLink.changeset(%{folder_uuid: folder_uuid, file_uuid: file_uuid})
1299 + |> repo().insert()
1300 + end
1301 + end
1302 +
1023 1303 @doc "Removes a folder link."
1024 1304 def delete_folder_link(link_uuid) do
1025 1305 case repo().get(FolderLink, link_uuid) do
  @@ -1043,6 +1323,7 @@ defmodule PhoenixKit.Modules.Storage do
1043 1323 """
1044 1324 def list_files(opts \\ []) do
1045 1325 PhoenixKit.Modules.Storage.File
1326 + |> where([f], f.status != "trashed")
1046 1327 |> maybe_filter_by_bucket(opts[:bucket_uuid])
1047 1328 |> maybe_order_by(opts[:order_by])
1048 1329 |> maybe_limit(opts[:limit])
  @@ -1153,8 +1434,15 @@ defmodule PhoenixKit.Modules.Storage do
1153 1434
1154 1435 @doc """
1155 1436 Returns the count of orphaned files.
1437 +
1438 + When scope_folder_id is set, returns 0 because orphaned files (folder_uuid IS NULL)
1439 + are always outside any non-nil scope.
1156 1440 """
1157 - def count_orphaned_files do
1441 + def count_orphaned_files(scope_folder_id \\ nil)
1442 +
1443 + def count_orphaned_files(scope_folder_id) when not is_nil(scope_folder_id), do: 0
1444 +
1445 + def count_orphaned_files(nil) do
1158 1446 orphaned_files_query()
1159 1447 |> repo().aggregate(:count, :uuid)
1160 1448 end
  @@ -1192,14 +1480,15 @@ defmodule PhoenixKit.Modules.Storage do
1192 1480 existing = existing_optional_tables()
1193 1481 protected_uuids = protected_file_uuids()
1194 1482
1195 - # Core check — phoenix_kit_users always exists
1483 + # Core check — phoenix_kit_users always exists; exclude trashed files
1196 1484 base =
1197 1485 from(f in PhoenixKit.Modules.Storage.File,
1198 1486 where:
1199 - fragment(
1200 - "NOT EXISTS (SELECT 1 FROM phoenix_kit_users u WHERE u.custom_fields->>'avatar_file_uuid' = ?::text)",
1201 - f.uuid
1202 - )
1487 + f.status != "trashed" and
1488 + fragment(
1489 + "NOT EXISTS (SELECT 1 FROM phoenix_kit_users u WHERE u.custom_fields->>'avatar_file_uuid' = ?::text)",
1490 + f.uuid
1491 + )
1203 1492 )
1204 1493
1205 1494 # Exclude UUIDs that the parent app has explicitly marked as protected
  @@ -1670,6 +1959,117 @@ defmodule PhoenixKit.Modules.Storage do
1670 1959 end
1671 1960 end
1672 1961
1962 + # ===== TRASH =====
1963 +
1964 + @doc "Moves a file to trash (soft-delete). Sets status to 'trashed' and records timestamp."
1965 + def trash_file(%PhoenixKit.Modules.Storage.File{} = file) do
1966 + file
1967 + |> Ecto.Changeset.change(%{
1968 + status: "trashed",
1969 + trashed_at: DateTime.utc_now() |> DateTime.truncate(:second)
1970 + })
1971 + |> repo().update()
1972 + end
1973 +
1974 + def trash_file(file_uuid) when is_binary(file_uuid) do
1975 + case get_file(file_uuid) do
1976 + nil -> {:error, :not_found}
1977 + file -> trash_file(file)
1978 + end
1979 + end
1980 +
1981 + @doc "Restores a trashed file back to active status."
1982 + def restore_file(%PhoenixKit.Modules.Storage.File{} = file) do
1983 + file
1984 + |> Ecto.Changeset.change(%{status: "active", trashed_at: nil})
1985 + |> repo().update()
1986 + end
1987 +
1988 + def restore_file(file_uuid) when is_binary(file_uuid) do
1989 + case get_file(file_uuid) do
1990 + nil -> {:error, :not_found}
1991 + file -> restore_file(file)
1992 + end
1993 + end
1994 +
1995 + @doc "Returns trashed files ordered by trashed_at descending, with pagination and optional scope."
1996 + def list_trashed_files(scope \\ nil, opts \\ []) do
1997 + query =
1998 + build_trashed_query(scope)
1999 + |> order_by([f], desc: f.trashed_at)
2000 +
2001 + query = if opts[:limit], do: limit(query, ^opts[:limit]), else: query
2002 + query = if opts[:offset], do: offset(query, ^opts[:offset]), else: query
2003 + repo().all(query)
2004 + end
2005 +
2006 + @doc "Returns the count of trashed files, optionally scoped."
2007 + def count_trashed_files(scope \\ nil) do
2008 + build_trashed_query(scope)
2009 + |> repo().aggregate(:count, :uuid)
2010 + end
2011 +
2012 + defp build_trashed_query(nil) do
2013 + from(f in PhoenixKit.Modules.Storage.File, where: f.status == "trashed")
2014 + end
2015 +
2016 + defp build_trashed_query(scope_folder_id) do
2017 + cte_base =
2018 + from(f in Folder,
2019 + where: f.uuid == ^scope_folder_id,
2020 + select: %{uuid: f.uuid}
2021 + )
2022 +
2023 + cte_recursive =
2024 + from(f in Folder,
2025 + join: d in "scope_descendants",
2026 + on: f.parent_uuid == d.uuid,
2027 + select: %{uuid: f.uuid}
2028 + )
2029 +
2030 + cte = union_all(cte_base, ^cte_recursive)
2031 +
2032 + from(f in PhoenixKit.Modules.Storage.File,
2033 + join: d in "scope_descendants",
2034 + on: f.folder_uuid == d.uuid,
2035 + where: f.status == "trashed"
2036 + )
2037 + |> with_cte("scope_descendants", as: ^cte)
2038 + end
2039 +
2040 + @doc "Permanently deletes all trashed files, optionally scoped."
2041 + def empty_trash(scope \\ nil) do
2042 + trashed = list_trashed_files(scope)
2043 + Enum.each(trashed, &delete_file_completely/1)
2044 + {:ok, length(trashed)}
2045 + end
2046 +
2047 + @doc "Permanently deletes trashed files older than the given number of days."
2048 + def prune_trash(days) do
2049 + cutoff = DateTime.utc_now() |> DateTime.add(-days * 86_400, :second)
2050 +
2051 + expired =
2052 + PhoenixKit.Modules.Storage.File
2053 + |> where([f], f.status == "trashed" and f.trashed_at < ^cutoff)
2054 + |> repo().all()
2055 +
2056 + Enum.each(expired, &delete_file_completely/1)
2057 + {:ok, length(expired)}
2058 + end
2059 +
2060 + @doc "Returns the configured trash retention period in days (default 30)."
2061 + def trash_retention_days do
2062 + Settings.get_setting("trash_retention_days", "30")
2063 + |> to_string()
2064 + |> Integer.parse()
2065 + |> case do
2066 + {n, _} -> n
2067 + :error -> 30
2068 + end
2069 + rescue
2070 + _ -> 30
2071 + end
2072 +
1673 2073 @doc """
1674 2074 Deletes a file completely - physical data from all storage buckets and database record.
Loading more files…