All checks were successful
deploy / deploy (push) Successful in 4m22s
Single activity_log table powering two views: chronological timeline on each order detail page (replacing the old fulfilment card) and a global feed at /admin/activity with tabs, category filters, search, and pagination. Real-time via PubSub — new entries appear instantly, nav badge updates across all admin pages. Instrumented across all event points: Stripe webhooks, order notifier, submission worker, fulfilment status worker, product sync worker, and Oban exhausted-job telemetry. Contextual action buttons (retry submission, retry sync, dismiss) with Oban unique constraints to prevent double-enqueue. 90-day pruning via cron. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
245 lines
6.0 KiB
Elixir
245 lines
6.0 KiB
Elixir
defmodule Berrypod.Orders.OrderNotifier do
|
|
@moduledoc """
|
|
Sends transactional emails for orders.
|
|
|
|
Order confirmation after payment, shipping notification when dispatched.
|
|
"""
|
|
|
|
import Swoosh.Email
|
|
|
|
alias Berrypod.{ActivityLog, Cart}
|
|
alias Berrypod.Mailer
|
|
|
|
require Logger
|
|
|
|
@doc """
|
|
Sends an order confirmation email after successful payment.
|
|
|
|
Skips silently if the order has no customer email.
|
|
"""
|
|
def deliver_order_confirmation(%{customer_email: nil}), do: {:ok, :no_email}
|
|
def deliver_order_confirmation(%{customer_email: ""}), do: {:ok, :no_email}
|
|
|
|
def deliver_order_confirmation(order) do
|
|
subject = "Order confirmed - #{order.order_number}"
|
|
|
|
body = """
|
|
==============================
|
|
|
|
Thanks for your order!
|
|
|
|
Order: #{order.order_number}
|
|
|
|
#{format_items(order.items)}
|
|
Total: #{Cart.format_price(order.total)}
|
|
|
|
#{format_shipping_address(order.shipping_address)}
|
|
We'll send you another email when your order ships.
|
|
|
|
==============================
|
|
"""
|
|
|
|
result = deliver(order.customer_email, subject, body)
|
|
|
|
case result do
|
|
{:ok, _} ->
|
|
ActivityLog.log_event(
|
|
"order.email.confirmation_sent",
|
|
"Order confirmation sent to #{order.customer_email}",
|
|
order_id: order.id
|
|
)
|
|
|
|
_ ->
|
|
:ok
|
|
end
|
|
|
|
result
|
|
end
|
|
|
|
@doc """
|
|
Sends a magic link for the customer to view their orders.
|
|
|
|
The link is time-limited (controlled by the caller's token expiry).
|
|
"""
|
|
def deliver_order_lookup(email, link) do
|
|
subject = "Your order lookup link"
|
|
|
|
body = """
|
|
==============================
|
|
|
|
Here's your link to view your orders:
|
|
|
|
#{link}
|
|
|
|
This link expires in 1 hour.
|
|
|
|
If you didn't request this, you can ignore this email.
|
|
|
|
==============================
|
|
"""
|
|
|
|
deliver(email, subject, body)
|
|
end
|
|
|
|
@doc """
|
|
Sends a shipping notification with tracking info.
|
|
|
|
Skips silently if the order has no customer email.
|
|
"""
|
|
def deliver_shipping_notification(%{customer_email: nil}), do: {:ok, :no_email}
|
|
def deliver_shipping_notification(%{customer_email: ""}), do: {:ok, :no_email}
|
|
|
|
def deliver_shipping_notification(order) do
|
|
subject = "Your order has shipped - #{order.order_number}"
|
|
|
|
body = """
|
|
==============================
|
|
|
|
Good news! Your order #{order.order_number} is on its way.
|
|
|
|
#{format_tracking(order)}
|
|
Thanks for shopping with us.
|
|
|
|
==============================
|
|
"""
|
|
|
|
result = deliver(order.customer_email, subject, body)
|
|
|
|
case result do
|
|
{:ok, _} ->
|
|
ActivityLog.log_event(
|
|
"order.email.shipping_sent",
|
|
"Shipping notification sent to #{order.customer_email}",
|
|
order_id: order.id,
|
|
payload: %{tracking_number: order.tracking_number}
|
|
)
|
|
|
|
_ ->
|
|
:ok
|
|
end
|
|
|
|
result
|
|
end
|
|
|
|
@doc """
|
|
Sends a cart recovery email to a customer who abandoned a Stripe checkout.
|
|
|
|
Plain text, no tracking pixels, single send. Includes an unsubscribe link.
|
|
"""
|
|
def deliver_cart_recovery(cart, order, unsubscribe_url) do
|
|
base_url = BerrypodWeb.Endpoint.url()
|
|
|
|
body = """
|
|
==============================
|
|
|
|
You recently started a checkout but didn't complete it.
|
|
|
|
Your cart had:
|
|
#{format_cart_items(order.items, base_url)}
|
|
Total: #{Cart.format_price(cart.cart_total)}
|
|
|
|
We're only sending this once.
|
|
|
|
Don't want to hear from us? Unsubscribe: #{unsubscribe_url}
|
|
|
|
==============================
|
|
"""
|
|
|
|
result = deliver(cart.customer_email, "You left something behind", body)
|
|
|
|
case result do
|
|
{:ok, _} ->
|
|
ActivityLog.log_event(
|
|
"abandoned_cart.email_sent",
|
|
"Recovery email sent to #{cart.customer_email}",
|
|
payload: %{email: cart.customer_email}
|
|
)
|
|
|
|
_ ->
|
|
:ok
|
|
end
|
|
|
|
result
|
|
end
|
|
|
|
# --- Private ---
|
|
|
|
defp deliver(recipient, subject, body) do
|
|
shop_name = Berrypod.Settings.get_setting("shop_name", "Berrypod")
|
|
from_address = Berrypod.Settings.get_setting("email_from_address", "contact@example.com")
|
|
|
|
email =
|
|
new()
|
|
|> to(recipient)
|
|
|> from({shop_name, from_address})
|
|
|> subject(subject)
|
|
|> text_body(body)
|
|
|
|
case Mailer.deliver(email) do
|
|
{:ok, _metadata} = result ->
|
|
result
|
|
|
|
{:error, reason} = error ->
|
|
Logger.warning("Failed to send email to #{recipient}: #{inspect(reason)}")
|
|
error
|
|
end
|
|
end
|
|
|
|
defp format_items(items) when is_list(items) do
|
|
items
|
|
|> Enum.map_join("\n", fn item ->
|
|
price = Cart.format_price(item.unit_price * item.quantity)
|
|
" #{item.quantity}x #{item.product_name} (#{item.variant_title}) - #{price}"
|
|
end)
|
|
end
|
|
|
|
defp format_items(_), do: ""
|
|
|
|
defp format_cart_items(items, base_url) when is_list(items) do
|
|
items
|
|
|> Enum.map_join("\n", fn item ->
|
|
price = Cart.format_price(item.unit_price * item.quantity)
|
|
line = " #{item.quantity}x #{item.product_name} (#{item.variant_title}) - #{price}"
|
|
|
|
if item.product_id do
|
|
line <> "\n #{base_url}/products/#{item.product_id}"
|
|
else
|
|
line
|
|
end
|
|
end)
|
|
end
|
|
|
|
defp format_cart_items(_, _), do: ""
|
|
|
|
defp format_shipping_address(address) when is_map(address) and map_size(address) > 0 do
|
|
lines =
|
|
[
|
|
address["name"],
|
|
address["line1"],
|
|
address["line2"],
|
|
[address["city"], address["postal_code"]] |> Enum.reject(&is_nil/1) |> Enum.join(" "),
|
|
address["state"],
|
|
address["country"]
|
|
]
|
|
|> Enum.reject(&(is_nil(&1) or &1 == ""))
|
|
|> Enum.map_join("\n", &" #{&1}")
|
|
|
|
"Shipping to:\n#{lines}\n\n"
|
|
end
|
|
|
|
defp format_shipping_address(_), do: ""
|
|
|
|
defp format_tracking(order) do
|
|
cond do
|
|
order.tracking_url not in [nil, ""] and order.tracking_number not in [nil, ""] ->
|
|
"Tracking: #{order.tracking_number}\n#{order.tracking_url}\n\n"
|
|
|
|
order.tracking_number not in [nil, ""] ->
|
|
"Tracking: #{order.tracking_number}\n\n"
|
|
|
|
true ->
|
|
"Tracking details will follow once the carrier updates.\n\n"
|
|
end
|
|
end
|
|
end
|