PhoenixKitAI.Translatable adapter for shop products.
Resource identity
resource_type is "shop_product"; resource_uuid is the product uuid.
Fields
%{"title", "description", "body", "seo_title", "seo_description"} from
the source language (Translations.get/3), non-empty only. "body" maps
to the schema's body_html (the shared prompt vocabulary uses body).
The slug is NEVER sourced from or trusted to the AI — it is regenerated
locally from the translated title, and only when the target language has
no slug yet, so re-translations can't change published URLs. This is a
write-once rule for the translation pipeline only: regenerate_slug/2 is
the explicit, one-off repair path for a slug already shaped by an older
version of this adapter, and it always recomputes from the current title
regardless of whether a slug exists. Callers doing a bulk repair are
responsible for their own redirect/history bookkeeping — this module has
none.
Concurrency
All languages share ONE product row's JSONB maps, so put_translation/4
re-reads the row under FOR UPDATE and merges against the latest
committed state (the publishing group-adapter pattern) — concurrent
per-language jobs serialize on the row lock and never drop a sibling
language. update_product/2 / update_product_translation/3 are
deliberately NOT used here: they write a stale in-memory struct without a
lock — the exact lost-update race this adapter must prevent.
Slug uniqueness within the language is checked app-side (suffix on collision); there is no DB unique constraint on the JSONB slug map (core migration v47 dropped it), so the check is best-effort across rows.
Prompt
The seo fields are not in the shared translation prompt's vocabulary, so
this adapter ships its own prompt (ensure_prompt/0, slug
phoenixkit-shop-product-translation). Host forms must pass its uuid
per job — the global ai_translation_prompt_uuid setting stays untouched.
Requires the optional phoenix_kit_ai plugin: ensure_prompt/0 returns
{:error, :ai_not_installed} when it is absent, and the whole adapter is
only reached through duck-typed discovery, which never runs without it.
Summary
Functions
Idempotently creates this adapter's translation prompt and returns its uuid — host forms pass it per job instead of the shared default prompt.
Recomputes and stores lang's slug from its CURRENT title, even when a
slug already exists. Explicit, one-off repair path — bypasses the
write-once rule put_translation/4 enforces for the translation
pipeline. Returns {:error, :no_title} when lang has no title, and
{:ok, %{old: slug, new: slug}} (unchanged) when the recomputed slug
equals the stored one. Broadcasts Events.broadcast_product_updated/1
only when the slug actually changes.
The resource-type key this adapter registers under.
Functions
Idempotently creates this adapter's translation prompt and returns its uuid — host forms pass it per job instead of the shared default prompt.
@spec regenerate_slug(String.t(), String.t(), keyword()) :: {:ok, %{old: String.t() | nil, new: String.t()}} | {:error, term()}
Recomputes and stores lang's slug from its CURRENT title, even when a
slug already exists. Explicit, one-off repair path — bypasses the
write-once rule put_translation/4 enforces for the translation
pipeline. Returns {:error, :no_title} when lang has no title, and
{:ok, %{old: slug, new: slug}} (unchanged) when the recomputed slug
equals the stored one. Broadcasts Events.broadcast_product_updated/1
only when the slug actually changes.
opts[:dry_run] (default false): when true, computes and returns the
same {:ok, %{old: old, new: new}} result WITHOUT writing anything — no
update, no broadcast. Lets a bulk repair task preview what would change.
The resource-type key this adapter registers under.