Packages

phoenix_kit

1.7.76
1.7.208 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

Files

Jump to
phoenix_kit lib modules shop services image_migration.ex
Raw

lib/modules/shop/services/image_migration.ex

defmodule PhoenixKit.Modules.Shop.Services.ImageMigration do
@moduledoc """
Orchestrates batch migration of product images from external URLs to Storage module.
Provides functions to query migration status, queue migration jobs,
and migrate individual products.
## Usage
# Get migration statistics
stats = ImageMigration.migration_stats()
# => %{total: 100, migrated: 25, pending: 75, failed: 0}
# Queue all pending products for migration
{:ok, count} = ImageMigration.queue_all_migrations(user_uuid)
# => {:ok, 75}
# Migrate a single product synchronously
{:ok, product} = ImageMigration.migrate_product(product_uuid, user_uuid)
"""
require Logger
import Ecto.Query
alias PhoenixKit.Modules.Shop
alias PhoenixKit.Modules.Shop.Product
alias PhoenixKit.Modules.Shop.Services.ImageDownloader
alias PhoenixKit.Modules.Shop.Workers.ImageMigrationWorker
@doc """
Returns products that need image migration.
A product needs migration if it has legacy image URLs but no Storage UUIDs.
## Options
* `:limit` - Maximum number of products to return (default: all)
* `:offset` - Number of products to skip (default: 0)
## Examples
iex> products_needing_migration()
[%Product{}, %Product{}, ...]
iex> products_needing_migration(limit: 10)
[%Product{}, ...]
"""
@spec products_needing_migration(keyword()) :: [Product.t()]
def products_needing_migration(opts \\ []) do
limit = Keyword.get(opts, :limit)
offset = Keyword.get(opts, :offset, 0)
query =
from(p in Product,
# Has legacy images (JSONB array) or featured_image URL
# No Storage-based images yet
where:
(fragment("jsonb_array_length(?) > 0", p.images) or
(not is_nil(p.featured_image) and p.featured_image != "")) and
is_nil(p.featured_image_uuid) and
fragment("COALESCE(array_length(?, 1), 0) = 0", p.image_uuids),
order_by: [asc: p.inserted_at]
)
query = if offset > 0, do: offset(query, ^offset), else: query
query = if limit, do: limit(query, ^limit), else: query
repo().all(query)
end
@doc """
Returns the count of products needing migration.
## Examples
iex> products_needing_migration_count()
75
"""
@spec products_needing_migration_count() :: non_neg_integer()
def products_needing_migration_count do
query =
from(p in Product,
where:
(fragment("jsonb_array_length(?) > 0", p.images) or
(not is_nil(p.featured_image) and p.featured_image != "")) and
is_nil(p.featured_image_uuid) and
fragment("COALESCE(array_length(?, 1), 0) = 0", p.image_uuids),
select: count(p.uuid)
)
repo().one(query) || 0
end
@doc """
Returns the count of products that have been migrated.
## Examples
iex> products_migrated_count()
25
"""
@spec products_migrated_count() :: non_neg_integer()
def products_migrated_count do
query =
from(p in Product,
where:
not is_nil(p.featured_image_uuid) or
fragment("array_length(?, 1) > 0", p.image_uuids),
select: count(p.uuid)
)
repo().one(query) || 0
end
@doc """
Returns migration statistics.
## Returns
A map with the following keys:
* `:total` - Total products with any images (legacy or storage)
* `:migrated` - Products that have storage-based images
* `:pending` - Products with legacy images but no storage images
* `:failed` - Count of failed migration jobs (from Oban)
* `:in_progress` - Count of currently running migration jobs
## Examples
iex> migration_stats()
%{total: 100, migrated: 25, pending: 75, failed: 0, in_progress: 5}
"""
@spec migration_stats() :: map()
def migration_stats do
pending = products_needing_migration_count()
migrated = products_migrated_count()
total = pending + migrated
# Get job stats from Oban
{in_progress, failed} = get_oban_job_stats()
%{
total: total,
migrated: migrated,
pending: pending,
failed: failed,
in_progress: in_progress
}
end
defp get_oban_job_stats do
# Count executing and available jobs
in_progress_query =
from(j in Oban.Job,
where:
j.worker == "PhoenixKit.Modules.Shop.Workers.ImageMigrationWorker" and
j.state in ["executing", "available", "scheduled"],
select: count(j.id)
)
# Count failed jobs (not retrying)
failed_query =
from(j in Oban.Job,
where:
j.worker == "PhoenixKit.Modules.Shop.Workers.ImageMigrationWorker" and
j.state == "discarded",
select: count(j.id)
)
in_progress = repo().one(in_progress_query) || 0
failed = repo().one(failed_query) || 0
{in_progress, failed}
end
@doc """
Queues migration jobs for all products needing migration.
Creates an Oban job for each product that has legacy images but no storage images.
## Options
* `:limit` - Maximum number of products to queue (default: all)
* `:priority` - Oban job priority (default: 3)
## Returns
* `{:ok, count}` - Number of jobs queued
* `{:error, reason}` - If queuing failed
## Examples
iex> queue_all_migrations(user_uuid)
{:ok, 75}
iex> queue_all_migrations(user_uuid, limit: 10)
{:ok, 10}
"""
@spec queue_all_migrations(String.t() | integer(), keyword()) ::
{:ok, non_neg_integer()} | {:error, term()}
def queue_all_migrations(user_uuid, opts \\ []) do
limit = Keyword.get(opts, :limit)
priority = Keyword.get(opts, :priority, 3)
products = products_needing_migration(limit: limit)
count = length(products)
Logger.info("Queuing image migration for #{count} products")
jobs =
Enum.map(products, fn product ->
ImageMigrationWorker.new(
%{product_uuid: product.uuid, user_uuid: user_uuid},
priority: priority
)
end)
inserted = Oban.insert_all(jobs)
broadcast_migration_started(count)
{:ok, length(inserted)}
end
@doc """
Cancels all pending migration jobs.
## Returns
* `{:ok, count}` - Number of jobs cancelled
"""
@spec cancel_pending_migrations() :: {:ok, non_neg_integer()}
def cancel_pending_migrations do
query =
from(j in Oban.Job,
where:
j.worker == "PhoenixKit.Modules.Shop.Workers.ImageMigrationWorker" and
j.state in ["available", "scheduled"]
)
{count, _} = repo().delete_all(query)
Logger.info("Cancelled #{count} pending migration jobs")
broadcast_migration_cancelled(count)
{:ok, count}
end
@doc """
Migrates a single product synchronously.
Downloads all legacy images and updates the product with storage UUIDs.
## Returns
* `{:ok, product}` - Updated product with storage image IDs
* `{:error, :already_migrated}` - Product already has storage images
* `{:error, :no_images}` - Product has no legacy images to migrate
* `{:error, reason}` - Migration failed
## Examples
iex> migrate_product(product_uuid, user_uuid)
{:ok, %Product{featured_image_uuid: "uuid-1", image_uuids: ["uuid-1", "uuid-2"]}}
"""
@spec migrate_product(String.t(), String.t() | integer()) ::
{:ok, Product.t()} | {:error, term()}
def migrate_product(product_uuid, user_uuid) do
case Shop.get_product(product_uuid) do
nil ->
{:error, :product_not_found}
product ->
do_migrate_product(product, user_uuid)
end
end
defp do_migrate_product(product, user_uuid) do
# Check if already migrated
if has_storage_images?(product) do
{:error, :already_migrated}
else
# Validate product has required fields
with :ok <- validate_product_for_migration(product) do
# Collect image URLs
image_urls = collect_image_urls(product)
if Enum.empty?(image_urls) do
{:error, :no_images}
else
migrate_images_for_product(product, image_urls, user_uuid)
end
end
end
end
defp validate_product_for_migration(product) do
cond do
is_nil(product.title) or product.title == %{} ->
Logger.warning("Product #{product.uuid} missing title, skipping migration")
{:error, :missing_title}
is_nil(product.slug) or product.slug == %{} ->
Logger.warning("Product #{product.uuid} missing slug, skipping migration")
{:error, :missing_slug}
true ->
:ok
end
end
defp has_storage_images?(product) do
not is_nil(product.featured_image_uuid) or
(is_list(product.image_uuids) and product.image_uuids != [])
end
defp collect_image_urls(product) do
urls = []
# Add featured_image URL if present
urls =
if is_binary(product.featured_image) and String.starts_with?(product.featured_image, "http") do
[product.featured_image | urls]
else
urls
end
# Add all images from the legacy images array
legacy_image_urls =
(product.images || [])
|> Enum.flat_map(fn
%{"src" => src} when is_binary(src) -> [src]
src when is_binary(src) -> [src]
_ -> []
end)
|> Enum.filter(&String.starts_with?(&1, "http"))
(urls ++ legacy_image_urls) |> Enum.uniq()
end
defp migrate_images_for_product(product, image_urls, user_uuid) do
# Validate URLs first to skip unavailable images
{valid_urls, invalid_urls} = ImageDownloader.validate_urls(image_urls)
if invalid_urls != [] do
Logger.warning(
"Product #{product.uuid}: #{length(invalid_urls)} invalid URLs skipped: #{inspect(invalid_urls)}"
)
end
if valid_urls == [] do
Logger.warning("Product #{product.uuid}: All image URLs invalid")
{:error, :all_urls_invalid}
else
Logger.info("Migrating #{length(valid_urls)} valid images for product #{product.uuid}")
# Download all images
results =
ImageDownloader.download_batch(valid_urls, user_uuid, concurrency: 3, timeout: 60_000)
# Build URL -> file_uuid mapping
url_to_file_uuid =
Enum.reduce(results, %{}, fn
{url, {:ok, file_uuid}}, acc ->
Map.put(acc, url, file_uuid)
{url, {:error, reason}}, acc ->
Logger.warning("Failed to download #{url}: #{inspect(reason)}")
acc
end)
if map_size(url_to_file_uuid) == 0 do
{:error, :all_downloads_failed}
else
update_product_images(product, url_to_file_uuid)
end
end
end
defp update_product_images(product, url_to_file_uuid) do
# Map featured_image to featured_image_uuid
featured_image_uuid = Map.get(url_to_file_uuid, product.featured_image)
# Map legacy images to image_uuids, preserving order from original images array
image_uuids =
(product.images || [])
|> Enum.flat_map(fn
%{"src" => src} -> [src]
src when is_binary(src) -> [src]
_ -> []
end)
|> Enum.map(&Map.get(url_to_file_uuid, &1))
|> Enum.reject(&is_nil/1)
# Use first image_id as featured if not set
featured_image_uuid = featured_image_uuid || List.first(image_uuids)
# Ensure featured image is first in image_uuids (no duplicates)
image_uuids =
if featured_image_uuid && featured_image_uuid in image_uuids do
[featured_image_uuid | Enum.reject(image_uuids, &(&1 == featured_image_uuid))]
else
image_uuids
end
# Update image mappings in metadata
metadata = update_image_mappings(product.metadata, url_to_file_uuid)
attrs = %{
featured_image_uuid: featured_image_uuid,
image_uuids: image_uuids,
metadata: metadata,
# Clear legacy fields after successful migration
images: [],
featured_image: nil
}
Shop.update_product(product, attrs)
end
defp update_image_mappings(nil, _url_to_file_uuid), do: nil
defp update_image_mappings(metadata, url_to_file_uuid) when is_map(metadata) do
case Map.get(metadata, "_image_mappings") do
nil ->
metadata
mappings when is_map(mappings) ->
updated_mappings =
Enum.reduce(mappings, %{}, fn {option_key, value_map}, acc ->
updated_value_map =
Enum.reduce(value_map, %{}, fn {value, image_ref}, inner_acc ->
new_ref = convert_url_to_file_uuid(image_ref, url_to_file_uuid)
Map.put(inner_acc, value, new_ref)
end)
Map.put(acc, option_key, updated_value_map)
end)
Map.put(metadata, "_image_mappings", updated_mappings)
end
end
defp update_image_mappings(metadata, _url_to_file_uuid), do: metadata
defp convert_url_to_file_uuid(image_ref, url_to_file_uuid)
when is_binary(image_ref) do
if String.starts_with?(image_ref, "http") do
Map.get(url_to_file_uuid, image_ref, image_ref)
else
image_ref
end
end
defp convert_url_to_file_uuid(image_ref, _url_to_file_uuid), do: image_ref
# PubSub broadcasts
defp broadcast_migration_started(count) do
PhoenixKit.PubSubHelper.broadcast(
"shop:image_migration:batch",
{:migration_started, %{total: count}}
)
end
defp broadcast_migration_cancelled(count) do
PhoenixKit.PubSubHelper.broadcast(
"shop:image_migration:batch",
{:migration_cancelled, %{cancelled: count}}
)
end
defp repo do
PhoenixKit.Config.get_repo()
end
end