add activity log with order timeline and global feed
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>
This commit is contained in:
jamey
2026-03-01 15:09:08 +00:00
parent b235219aee
commit 580a7203c9
23 changed files with 1716 additions and 54 deletions

View File

@@ -0,0 +1,175 @@
defmodule Berrypod.ActivityLog do
@moduledoc """
Records and queries meaningful system events (orders, syncs, emails,
abandoned carts). Powers the order timeline and global activity feed.
"""
import Ecto.Query
require Logger
alias Berrypod.Repo
alias Berrypod.ActivityLog.Entry
# ── Write ──
@doc """
Records an activity event. Fire-and-forget — never raises, never crashes
the calling process.
## Options
* `:level` - "info" (default), "warning", or "error"
* `:order_id` - links the event to an order
* `:payload` - arbitrary map of snapshot data
* `:occurred_at` - when the event happened (defaults to now)
"""
def log_event(event_type, message, opts \\ []) do
attrs = %{
event_type: event_type,
level: opts[:level] || "info",
order_id: opts[:order_id],
payload: opts[:payload] || %{},
message: message,
occurred_at: opts[:occurred_at] || DateTime.utc_now() |> DateTime.truncate(:second)
}
case %Entry{} |> Entry.changeset(attrs) |> Repo.insert() do
{:ok, entry} ->
broadcast(entry)
{:ok, entry}
{:error, changeset} ->
Logger.warning("ActivityLog insert failed: #{inspect(changeset.errors)}")
:ok
end
rescue
e ->
Logger.warning("ActivityLog insert crashed: #{Exception.message(e)}")
:ok
end
# ── Read ──
@doc """
Returns all activity entries for an order, oldest first.
"""
def list_for_order(order_id) do
Entry
|> where([a], a.order_id == ^order_id)
|> order_by([a], asc: a.occurred_at)
|> Repo.all()
end
@doc """
Paginated activity feed for the global activity page.
## Options
* `:page` - page number (default 1)
* `:tab` - "all" (default) or "attention" (unresolved warnings/errors)
* `:category` - "orders", "syncs", "emails", "carts", or nil for all
* `:search` - order number substring search
"""
def list_recent(opts \\ []) do
Entry
|> order_by([a], desc: a.occurred_at)
|> maybe_filter_tab(opts[:tab])
|> maybe_filter_category(opts[:category])
|> maybe_search_order_number(opts[:search])
|> Berrypod.Pagination.paginate(page: opts[:page], per_page: 50)
end
@doc """
Count of unresolved warnings and errors.
"""
def count_needing_attention do
Entry
|> where([a], a.level in ["warning", "error"] and is_nil(a.resolved_at))
|> Repo.aggregate(:count)
end
# ── Resolve ──
@doc """
Mark a single entry as resolved.
"""
def resolve(id) do
Entry
|> where([a], a.id == ^id)
|> Repo.update_all(set: [resolved_at: DateTime.utc_now() |> DateTime.truncate(:second)])
end
@doc """
Resolve all unresolved entries for an order.
"""
def resolve_all_for_order(order_id) do
Entry
|> where([a], a.order_id == ^order_id and is_nil(a.resolved_at))
|> Repo.update_all(set: [resolved_at: DateTime.utc_now() |> DateTime.truncate(:second)])
end
# ── PubSub ──
@doc """
Subscribe to all activity events.
"""
def subscribe do
Phoenix.PubSub.subscribe(Berrypod.PubSub, "activity:all")
end
@doc """
Subscribe to activity events for a specific order.
"""
def subscribe(order_id) do
Phoenix.PubSub.subscribe(Berrypod.PubSub, "activity:order:#{order_id}")
end
# ── Private ──
defp broadcast(entry) do
Phoenix.PubSub.broadcast(Berrypod.PubSub, "activity:all", {:new_activity, entry})
if entry.order_id do
Phoenix.PubSub.broadcast(
Berrypod.PubSub,
"activity:order:#{entry.order_id}",
{:new_activity, entry}
)
end
end
defp maybe_filter_tab(query, "attention") do
where(query, [a], a.level in ["warning", "error"] and is_nil(a.resolved_at))
end
defp maybe_filter_tab(query, _), do: query
defp maybe_filter_category(query, "orders") do
where(query, [a], like(a.event_type, "order.%"))
end
defp maybe_filter_category(query, "syncs") do
where(query, [a], like(a.event_type, "sync.%"))
end
defp maybe_filter_category(query, "emails") do
where(query, [a], like(a.event_type, "%.email.%"))
end
defp maybe_filter_category(query, "carts") do
where(query, [a], like(a.event_type, "abandoned_cart.%"))
end
defp maybe_filter_category(query, _), do: query
defp maybe_search_order_number(query, search) when is_binary(search) and search != "" do
query
|> join(:inner, [a], o in "orders", on: a.order_id == o.id)
|> where([_a, o], like(o.order_number, ^"%#{search}%"))
end
defp maybe_search_order_number(query, _), do: query
end

View File

@@ -0,0 +1,36 @@
defmodule Berrypod.ActivityLog.Entry do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
@levels ~w(info warning error)
schema "activity_log" do
field :event_type, :string
field :level, :string, default: "info"
field :order_id, :binary_id
field :payload, :map, default: %{}
field :message, :string
field :resolved_at, :utc_datetime
field :occurred_at, :utc_datetime
timestamps(type: :utc_datetime)
end
def changeset(entry, attrs) do
entry
|> cast(attrs, [
:event_type,
:level,
:order_id,
:payload,
:message,
:resolved_at,
:occurred_at
])
|> validate_required([:event_type, :level, :message, :occurred_at])
|> validate_inclusion(:level, @levels)
end
end

View File

@@ -0,0 +1,35 @@
defmodule Berrypod.ActivityLog.ObanTelemetryHandler do
@moduledoc """
Captures Oban job exhaustion (all retries failed) as activity log entries.
Attached in Application.start/2.
"""
def attach do
:telemetry.attach(
"activity-log-oban-exhausted",
[:oban, :job, :exception],
&__MODULE__.handle_event/4,
[]
)
end
def handle_event([:oban, :job, :exception], _measurements, metadata, _config) do
# Only log when job is being discarded (exhausted all retries)
if metadata.state == :discard do
worker = metadata.job.worker
queue = metadata.job.queue
error = Exception.message(metadata.reason)
Berrypod.ActivityLog.log_event(
"job.exhausted",
"Background job exhausted all retries: #{worker}",
level: "error",
payload: %{
worker: worker,
queue: queue,
error: String.slice(error, 0, 500)
}
)
end
end
end

View File

@@ -0,0 +1,26 @@
defmodule Berrypod.ActivityLog.PruneWorker do
@moduledoc """
Nightly Oban cron job that prunes activity log entries older than 90 days.
"""
use Oban.Worker, queue: :default, max_attempts: 1
import Ecto.Query
alias Berrypod.Repo
alias Berrypod.ActivityLog.Entry
@impl Oban.Worker
def perform(_job) do
cutoff = DateTime.utc_now() |> DateTime.add(-90, :day)
{count, _} = Repo.delete_all(from(a in Entry, where: a.inserted_at < ^cutoff))
if count > 0 do
require Logger
Logger.info("Pruned #{count} activity log entries older than 90 days")
end
:ok
end
end

View File

@@ -10,6 +10,7 @@ defmodule Berrypod.Application do
# Create ETS table here so the supervisor process owns it (lives forever).
# The Task below only warms it with data from the DB.
Berrypod.Redirects.create_table()
Berrypod.ActivityLog.ObanTelemetryHandler.attach()
children = [
BerrypodWeb.Telemetry,

View File

@@ -9,7 +9,7 @@ defmodule Berrypod.Orders.FulfilmentStatusWorker do
use Oban.Worker, queue: :sync, max_attempts: 1
alias Berrypod.Orders
alias Berrypod.{ActivityLog, Orders}
require Logger
@@ -38,6 +38,8 @@ defmodule Berrypod.Orders.FulfilmentStatusWorker do
Logger.info(
"Order #{order.order_number} status: #{order.fulfilment_status}#{updated.fulfilment_status}"
)
log_status_change(order, updated)
end
{:error, reason} ->
@@ -46,4 +48,42 @@ defmodule Berrypod.Orders.FulfilmentStatusWorker do
)
end
end
defp log_status_change(order, updated) do
{event_type, message, level, payload} =
case updated.fulfilment_status do
"processing" ->
{"order.production", "In production", "info", %{}}
"shipped" ->
{"order.shipped", "Shipped#{tracking_suffix(updated)}", "info",
%{tracking_number: updated.tracking_number, tracking_url: updated.tracking_url}}
"delivered" ->
{"order.delivered", "Delivered", "info", %{}}
"failed" ->
{"order.submission_failed",
"Fulfilment failed: #{updated.fulfilment_error || "unknown error"}", "error",
%{error: updated.fulfilment_error}}
"cancelled" ->
{"order.cancelled", "Order cancelled", "warning", %{}}
other ->
{"order.status_changed", "Status changed to #{other}", "info", %{}}
end
ActivityLog.log_event(event_type, message,
level: level,
order_id: order.id,
payload: payload
)
end
defp tracking_suffix(%{tracking_number: num}) when is_binary(num) and num != "" do
" — tracking: #{num}"
end
defp tracking_suffix(_), do: ""
end

View File

@@ -7,7 +7,7 @@ defmodule Berrypod.Orders.OrderNotifier do
import Swoosh.Email
alias Berrypod.Cart
alias Berrypod.{ActivityLog, Cart}
alias Berrypod.Mailer
require Logger
@@ -39,7 +39,21 @@ defmodule Berrypod.Orders.OrderNotifier do
==============================
"""
deliver(order.customer_email, subject, body)
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 """
@@ -89,7 +103,22 @@ defmodule Berrypod.Orders.OrderNotifier do
==============================
"""
deliver(order.customer_email, subject, body)
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 """
@@ -116,7 +145,21 @@ defmodule Berrypod.Orders.OrderNotifier do
==============================
"""
deliver(cart.customer_email, "You left something behind", body)
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 ---

View File

@@ -7,14 +7,14 @@ defmodule Berrypod.Orders.OrderSubmissionWorker do
Retries up to 3 times with backoff for transient failures.
"""
use Oban.Worker, queue: :checkout, max_attempts: 3
use Oban.Worker, queue: :checkout, max_attempts: 3, unique: [period: 60]
alias Berrypod.Orders
alias Berrypod.{ActivityLog, Orders}
require Logger
@impl Oban.Worker
def perform(%Oban.Job{args: %{"order_id" => order_id}}) do
def perform(%Oban.Job{args: %{"order_id" => order_id}} = job) do
case Orders.get_order(order_id) do
nil ->
Logger.warning("Order submission: order #{order_id} not found")
@@ -39,10 +39,30 @@ defmodule Berrypod.Orders.OrderSubmissionWorker do
"Order #{updated.order_number} submitted to provider (#{updated.provider_order_id})"
)
ActivityLog.log_event(
"order.submitted",
"Submitted to provider (#{updated.provider_order_id})",
order_id: order.id,
payload: %{provider_order_id: updated.provider_order_id}
)
:ok
{:error, reason} ->
Logger.error("Order #{order.order_number} submission failed: #{inspect(reason)}")
ActivityLog.log_event(
"order.submission_failed",
"Submission failed: #{inspect(reason)}",
level: "error",
order_id: order.id,
payload: %{
error: inspect(reason),
attempt: job.attempt,
max_attempts: job.max_attempts
}
)
{:error, reason}
end
end

View File

@@ -15,9 +15,9 @@ defmodule Berrypod.Sync.ProductSyncWorker do
* `provider_connection_id` - The ID of the provider connection to sync
"""
use Oban.Worker, queue: :sync, max_attempts: 3
use Oban.Worker, queue: :sync, max_attempts: 3, unique: [period: 60]
alias Berrypod.Products
alias Berrypod.{ActivityLog, Products}
alias Berrypod.Products.ProviderConnection
alias Berrypod.Providers.Provider
alias Berrypod.Sync.ImageDownloadWorker
@@ -66,6 +66,11 @@ defmodule Berrypod.Sync.ProductSyncWorker do
defp sync_products(conn) do
Logger.info("Starting product sync for #{conn.provider_type} (#{conn.id})")
ActivityLog.log_event("sync.started", "Product sync started (#{conn.provider_type})",
payload: %{provider_type: conn.provider_type, connection_id: conn.id}
)
Products.update_sync_status(conn, "syncing")
broadcast_sync(conn.id, {:sync_status, "syncing"})
@@ -97,6 +102,18 @@ defmodule Berrypod.Sync.ProductSyncWorker do
"#{created} created, #{updated} updated, #{unchanged} unchanged, #{errors} errors"
)
ActivityLog.log_event(
"sync.completed",
"Product sync complete — #{created + updated + unchanged} products, #{created} created, #{updated} updated",
payload: %{
provider_type: conn.provider_type,
created: created,
updated: updated,
unchanged: unchanged,
errors: errors
}
)
# Enqueue mockup enrichment for Printful products (extra angle images)
if conn.provider_type == "printful" do
enqueue_mockup_enrichment(conn, results)
@@ -116,6 +133,16 @@ defmodule Berrypod.Sync.ProductSyncWorker do
else
{:error, reason} = error ->
Logger.error("Product sync failed for #{conn.provider_type}: #{inspect(reason)}")
ActivityLog.log_event("sync.failed", "Product sync failed: #{inspect(reason)}",
level: "error",
payload: %{
provider_type: conn.provider_type,
connection_id: conn.id,
error: inspect(reason)
}
)
Products.update_sync_status(conn, "failed")
broadcast_sync(conn.id, {:sync_status, "failed"})
error

View File

@@ -5,7 +5,7 @@ defmodule BerrypodWeb.AdminLayoutHook do
"""
import Phoenix.Component
alias Berrypod.Settings
alias Berrypod.{ActivityLog, Settings}
alias Berrypod.Theme.{CSSCache, CSSGenerator}
def on_mount(:assign_current_path, _params, _session, socket) do
@@ -22,6 +22,8 @@ defmodule BerrypodWeb.AdminLayoutHook do
css
end
if Phoenix.LiveView.connected?(socket), do: ActivityLog.subscribe()
socket =
socket
|> assign(:current_path, "")
@@ -29,6 +31,7 @@ defmodule BerrypodWeb.AdminLayoutHook do
|> assign(:email_configured, Berrypod.Mailer.email_configured?())
|> assign(:theme_settings, theme_settings)
|> assign(:generated_css, generated_css)
|> assign(:attention_count, ActivityLog.count_needing_attention())
|> Phoenix.LiveView.attach_hook(:set_current_path, :handle_params, fn _params,
uri,
socket ->
@@ -37,6 +40,13 @@ defmodule BerrypodWeb.AdminLayoutHook do
|> assign(:current_path, URI.parse(uri).path)
|> assign(:site_live, Settings.site_live?())}
end)
|> Phoenix.LiveView.attach_hook(:update_attention_count, :handle_info, fn
{:new_activity, _entry}, socket ->
{:cont, assign(socket, :attention_count, ActivityLog.count_needing_attention())}
_other, socket ->
{:cont, socket}
end)
{:cont, socket}
end

View File

@@ -80,6 +80,20 @@
<.icon name="hero-shopping-bag" class="size-5" /> Orders
</.link>
</li>
<li>
<.link
navigate={~p"/admin/activity"}
class={admin_nav_active?(@current_path, "/admin/activity")}
>
<.icon name="hero-bell" class="size-5" /> Activity
<span
:if={@attention_count > 0}
class="admin-badge admin-badge-sm admin-badge-warning ml-auto"
>
{@attention_count}
</span>
</.link>
</li>
<li>
<.link
navigate={~p"/admin/products"}

View File

@@ -1,7 +1,7 @@
defmodule BerrypodWeb.StripeWebhookController do
use BerrypodWeb, :controller
alias Berrypod.Orders
alias Berrypod.{ActivityLog, Orders}
alias Berrypod.Orders.{OrderNotifier, OrderSubmissionWorker}
require Logger
@@ -69,6 +69,17 @@ defmodule BerrypodWeb.StripeWebhookController do
{:order_paid, order}
)
ActivityLog.log_event(
"order.created",
"Order placed — #{Berrypod.Cart.format_price(order.total)} via Stripe",
order_id: order.id,
payload: %{
total: order.total,
currency: order.currency,
payment_intent: payment_intent_id
}
)
OrderNotifier.deliver_order_confirmation(order)
# Submit to fulfilment provider
@@ -138,6 +149,14 @@ defmodule BerrypodWeb.StripeWebhookController do
case Orders.create_abandoned_cart(attrs) do
{:ok, cart} ->
Berrypod.Orders.AbandonedCartEmailWorker.enqueue(cart.id)
ActivityLog.log_event(
"abandoned_cart.created",
"Abandoned cart — #{email}, #{Berrypod.Cart.format_price(order.total)}",
order_id: order.id,
payload: %{email: email, total: order.total}
)
Logger.info("Abandoned cart recorded for #{email}, recovery email scheduled")
{:error, reason} ->

View File

@@ -0,0 +1,407 @@
defmodule BerrypodWeb.Admin.Activity do
use BerrypodWeb, :live_view
alias Berrypod.{ActivityLog, Orders}
alias Berrypod.Sync.ProductSyncWorker
@valid_tabs ~w(all attention)
@valid_categories ~w(orders syncs emails carts)
@impl true
def mount(_params, _session, socket) do
if connected?(socket), do: ActivityLog.subscribe()
socket =
socket
|> assign(:page_title, "Activity")
|> assign(:attention_count, ActivityLog.count_needing_attention())
{:ok, socket}
end
@impl true
def handle_params(params, _uri, socket) do
tab = if params["tab"] in @valid_tabs, do: params["tab"], else: "all"
category = if params["category"] in @valid_categories, do: params["category"], else: nil
search = params["search"]
page_num = Berrypod.Pagination.parse_page(params)
page =
ActivityLog.list_recent(
page: page_num,
tab: tab,
category: category,
search: search
)
socket =
socket
|> assign(:tab, tab)
|> assign(:category, category)
|> assign(:search, search || "")
|> assign(:pagination, page)
|> stream(:entries, page.items, reset: true)
{:noreply, socket}
end
@impl true
def handle_info({:new_activity, entry}, socket) do
socket = assign(socket, :attention_count, ActivityLog.count_needing_attention())
# Only prepend to stream if on page 1 and entry matches current filters
socket =
if socket.assigns.pagination.page == 1 and matches_filters?(entry, socket.assigns) do
stream_insert(socket, :entries, entry, at: 0)
else
socket
end
{:noreply, socket}
end
@impl true
def handle_event("tab", %{"tab" => tab}, socket) do
params = build_params(tab: tab)
{:noreply, push_patch(socket, to: ~p"/admin/activity?#{params}")}
end
def handle_event("category", %{"category" => category}, socket) do
# Toggle: clicking the active category clears it
category = if category == socket.assigns.category, do: nil, else: category
params =
build_params(tab: socket.assigns.tab, category: category, search: socket.assigns.search)
{:noreply, push_patch(socket, to: ~p"/admin/activity?#{params}")}
end
def handle_event("search", %{"search" => %{"query" => query}}, socket) do
search = if query == "", do: nil, else: query
params =
build_params(tab: socket.assigns.tab, category: socket.assigns.category, search: search)
{:noreply, push_patch(socket, to: ~p"/admin/activity?#{params}")}
end
def handle_event("resolve", %{"id" => id}, socket) do
ActivityLog.resolve(id)
{:noreply, refetch_and_reassign(socket)}
end
def handle_event("retry_submission", %{"order-id" => order_id, "entry-id" => entry_id}, socket) do
ActivityLog.resolve(entry_id)
case Orders.OrderSubmissionWorker.enqueue(order_id) do
{:ok, _job} ->
{:noreply,
socket
|> put_flash(:info, "Submission retry enqueued")
|> refetch_and_reassign()}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, "Failed to enqueue retry")}
end
end
def handle_event(
"retry_sync",
%{"connection-id" => connection_id, "entry-id" => entry_id},
socket
) do
ActivityLog.resolve(entry_id)
case ProductSyncWorker.enqueue(connection_id) do
{:ok, _job} ->
{:noreply,
socket
|> put_flash(:info, "Sync retry enqueued")
|> refetch_and_reassign()}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, "Failed to enqueue sync")}
end
end
@impl true
def render(assigns) do
~H"""
<.header>
Activity
</.header>
<%!-- tabs --%>
<div class="flex gap-2 mt-6 mb-4 flex-wrap">
<button
phx-click="tab"
phx-value-tab="all"
class={[
"admin-btn admin-btn-sm",
@tab == "all" && "admin-btn-primary",
@tab != "all" && "admin-btn-ghost"
]}
>
All
</button>
<button
phx-click="tab"
phx-value-tab="attention"
class={[
"admin-btn admin-btn-sm",
@tab == "attention" && "admin-btn-primary",
@tab != "attention" && "admin-btn-ghost"
]}
>
Needs attention
<span
:if={@attention_count > 0}
class="admin-badge admin-badge-sm admin-badge-warning ml-1"
>
{@attention_count}
</span>
</button>
</div>
<%!-- category chips + search --%>
<div class="flex flex-wrap items-center gap-2 mb-4">
<.category_chip category={nil} active={@category} label="All" />
<.category_chip category="orders" active={@category} label="Orders" />
<.category_chip category="syncs" active={@category} label="Syncs" />
<.category_chip category="emails" active={@category} label="Emails" />
<.category_chip category="carts" active={@category} label="Carts" />
<div class="ml-auto">
<.form for={%{}} phx-submit="search" as={:search} class="flex gap-2">
<input
type="text"
name="search[query]"
value={@search}
placeholder="Search by order number"
class="admin-input admin-input-sm"
/>
<button type="submit" class="admin-btn admin-btn-sm admin-btn-ghost">
<.icon name="hero-magnifying-glass-mini" class="size-4" />
</button>
</.form>
</div>
</div>
<%!-- entries --%>
<div id="activity-entries" phx-update="stream">
<div id="activity-empty" class="hidden only:block text-sm text-base-content/60 py-8 text-center">
No activity to show.
</div>
<div :for={{dom_id, entry} <- @streams.entries} id={dom_id} class="admin-activity-row">
<div class={["admin-activity-icon", activity_icon_class(entry.level)]}>
<.icon name={activity_icon(entry.level)} class="size-4" />
</div>
<div class="admin-activity-body">
<span class="admin-activity-type">{format_event_type(entry.event_type)}</span>
<span>{entry.message}</span>
<.link
:if={entry.order_id}
navigate={~p"/admin/orders/#{entry.order_id}"}
class="text-primary hover:underline text-xs ml-1"
>
View order &rarr;
</.link>
</div>
<div class="admin-activity-meta">
<time class="admin-activity-time" datetime={DateTime.to_iso8601(entry.occurred_at)}>
{relative_time(entry.occurred_at)}
</time>
<.entry_action entry={entry} />
</div>
</div>
</div>
<.admin_pagination
page={@pagination}
patch={~p"/admin/activity"}
params={build_params(tab: @tab, category: @category, search: @search)}
/>
"""
end
# ── Components ──
defp category_chip(assigns) do
active = assigns.category == assigns.active
assigns = assign(assigns, :active, active)
~H"""
<button
phx-click="category"
phx-value-category={@category || ""}
class={[
"admin-btn admin-btn-xs",
@active && "admin-btn-primary",
!@active && "admin-btn-ghost"
]}
>
{@label}
</button>
"""
end
defp entry_action(
%{entry: %{event_type: "order.submission_failed", order_id: order_id}} = assigns
)
when not is_nil(order_id) do
~H"""
<button
phx-click="retry_submission"
phx-value-order-id={@entry.order_id}
phx-value-entry-id={@entry.id}
class="admin-btn admin-btn-xs admin-btn-primary"
>
Retry submission
</button>
"""
end
defp entry_action(%{entry: %{event_type: "sync.failed", payload: payload}} = assigns) do
connection_id = payload["connection_id"] || payload[:connection_id]
assigns = assign(assigns, :connection_id, connection_id)
~H"""
<button
:if={@connection_id}
phx-click="retry_sync"
phx-value-connection-id={@connection_id}
phx-value-entry-id={@entry.id}
class="admin-btn admin-btn-xs admin-btn-primary"
>
Retry sync
</button>
<button
:if={!@connection_id}
phx-click="resolve"
phx-value-id={@entry.id}
class="admin-btn admin-btn-xs admin-btn-ghost"
>
Dismiss
</button>
"""
end
defp entry_action(%{entry: %{event_type: "job.exhausted", payload: payload}} = assigns) do
# If the exhausted job was an order submission worker and we have an order_id, offer retry
worker = payload["worker"] || payload[:worker] || ""
order_related = String.contains?(worker, "OrderSubmission")
assigns = assign(assigns, :order_related, order_related)
~H"""
<button
:if={@order_related && @entry.order_id}
phx-click="retry_submission"
phx-value-order-id={@entry.order_id}
phx-value-entry-id={@entry.id}
class="admin-btn admin-btn-xs admin-btn-primary"
>
Retry submission
</button>
<button
:if={!@order_related || !@entry.order_id}
phx-click="resolve"
phx-value-id={@entry.id}
class="admin-btn admin-btn-xs admin-btn-ghost"
>
Dismiss
</button>
"""
end
defp entry_action(%{entry: %{level: level, resolved_at: nil}} = assigns)
when level in ["warning", "error"] do
~H"""
<button
phx-click="resolve"
phx-value-id={@entry.id}
class="admin-btn admin-btn-xs admin-btn-ghost"
>
Dismiss
</button>
"""
end
defp entry_action(assigns) do
~H""
end
# ── Helpers ──
defp activity_icon("error"), do: "hero-x-circle-mini"
defp activity_icon("warning"), do: "hero-exclamation-triangle-mini"
defp activity_icon(_), do: "hero-check-circle-mini"
defp activity_icon_class("error"), do: "text-red-500"
defp activity_icon_class("warning"), do: "text-amber-500"
defp activity_icon_class(_), do: "text-green-500"
defp format_event_type(event_type) do
event_type
|> String.replace(".", " ")
|> String.replace("_", " ")
end
defp relative_time(datetime) do
now = DateTime.utc_now()
diff = DateTime.diff(now, datetime, :second)
cond do
diff < 60 -> "just now"
diff < 3600 -> "#{div(diff, 60)}m ago"
diff < 86400 -> "#{div(diff, 3600)}h ago"
diff < 604_800 -> "#{div(diff, 86400)}d ago"
true -> Calendar.strftime(datetime, "%d %b %Y")
end
end
defp matches_filters?(entry, assigns) do
tab_match =
case assigns.tab do
"attention" -> entry.level in ["warning", "error"] and is_nil(entry.resolved_at)
_ -> true
end
category_match =
case assigns.category do
"orders" -> String.starts_with?(entry.event_type, "order.")
"syncs" -> String.starts_with?(entry.event_type, "sync.")
"emails" -> String.contains?(entry.event_type, ".email.")
"carts" -> String.starts_with?(entry.event_type, "abandoned_cart.")
_ -> true
end
tab_match and category_match
end
defp build_params(opts) do
%{}
|> then(fn p ->
if opts[:tab] && opts[:tab] != "all", do: Map.put(p, "tab", opts[:tab]), else: p
end)
|> then(fn p -> if opts[:category], do: Map.put(p, "category", opts[:category]), else: p end)
|> then(fn p -> if opts[:search], do: Map.put(p, "search", opts[:search]), else: p end)
end
defp refetch_page(assigns) do
ActivityLog.list_recent(
page: assigns.pagination.page,
tab: assigns.tab,
category: assigns.category,
search: assigns.search
)
end
defp refetch_and_reassign(socket) do
page = refetch_page(socket.assigns)
socket
|> assign(:pagination, page)
|> assign(:attention_count, ActivityLog.count_needing_attention())
|> stream(:entries, page.items, reset: true)
end
end

View File

@@ -1,7 +1,7 @@
defmodule BerrypodWeb.Admin.OrderShow do
use BerrypodWeb, :live_view
alias Berrypod.Orders
alias Berrypod.{ActivityLog, Orders}
alias Berrypod.Cart
@impl true
@@ -16,15 +16,25 @@ defmodule BerrypodWeb.Admin.OrderShow do
{:ok, socket}
order ->
if connected?(socket), do: ActivityLog.subscribe(order.id)
timeline = ActivityLog.list_for_order(order.id)
socket =
socket
|> assign(:page_title, order.order_number)
|> assign(:order, order)
|> assign(:timeline, timeline)
{:ok, socket}
end
end
@impl true
def handle_info({:new_activity, entry}, socket) do
{:noreply, assign(socket, :timeline, socket.assigns.timeline ++ [entry])}
end
@impl true
def render(assigns) do
~H"""
@@ -95,43 +105,14 @@ defmodule BerrypodWeb.Admin.OrderShow do
</div>
</div>
<%!-- fulfilment --%>
<%!-- timeline --%>
<div class="admin-card mt-6">
<div class="admin-card-body">
<div class="flex items-center justify-between">
<h3 class="admin-card-title">Fulfilment</h3>
<h3 class="admin-card-title">Timeline</h3>
<.fulfilment_badge status={@order.fulfilment_status} />
</div>
<.list>
<:item :if={@order.provider_order_id} title="Provider order ID">
<code class="text-xs">{@order.provider_order_id}</code>
</:item>
<:item :if={@order.provider_status} title="Provider status">
{@order.provider_status}
</:item>
<:item :if={@order.submitted_at} title="Submitted">
{format_date(@order.submitted_at)}
</:item>
<:item :if={@order.tracking_number} title="Tracking">
<%= if @order.tracking_url do %>
<a href={@order.tracking_url} target="_blank" class="admin-link">
{@order.tracking_number}
</a>
<% else %>
{@order.tracking_number}
<% end %>
</:item>
<:item :if={@order.shipped_at} title="Shipped">
{format_date(@order.shipped_at)}
</:item>
<:item :if={@order.delivered_at} title="Delivered">
{format_date(@order.delivered_at)}
</:item>
<:item :if={@order.fulfilment_error} title="Error">
<span class="text-error text-sm">{@order.fulfilment_error}</span>
</:item>
</.list>
<div class="flex gap-2 mt-4">
<div class="flex gap-2 mt-2 mb-4">
<button
:if={can_submit?(@order)}
phx-click="submit_to_provider"
@@ -150,6 +131,23 @@ defmodule BerrypodWeb.Admin.OrderShow do
<.icon name="hero-arrow-path-mini" class="size-4" /> Refresh status
</button>
</div>
<div
:if={@order.tracking_number not in [nil, ""]}
class="flex items-center gap-2 mb-4 text-sm"
>
<.icon name="hero-truck-mini" class="size-4 text-base-content/60" />
<span class="font-medium">{@order.tracking_number}</span>
<a
:if={@order.tracking_url not in [nil, ""]}
href={@order.tracking_url}
target="_blank"
rel="noopener"
class="text-primary hover:underline"
>
Track shipment &rarr;
</a>
</div>
<.order_timeline entries={@timeline} />
</div>
</div>
@@ -234,6 +232,43 @@ defmodule BerrypodWeb.Admin.OrderShow do
end
end
# ── Components ──
defp order_timeline(assigns) do
~H"""
<div :if={@entries == []} class="text-sm text-base-content/60 py-4">
No activity recorded yet.
</div>
<ol :if={@entries != []} class="admin-timeline" id="order-timeline">
<li :for={entry <- @entries} class="admin-timeline-item">
<div class={["admin-timeline-dot", timeline_dot_class(entry.level)]}></div>
<div class="admin-timeline-content">
<p class="admin-timeline-message">{entry.message}</p>
<time class="admin-timeline-time" datetime={DateTime.to_iso8601(entry.occurred_at)}>
{format_timeline_time(entry.occurred_at)}
</time>
</div>
</li>
</ol>
"""
end
defp timeline_dot_class("error"), do: "admin-timeline-dot-error"
defp timeline_dot_class("warning"), do: "admin-timeline-dot-warning"
defp timeline_dot_class(_), do: "admin-timeline-dot-info"
defp format_timeline_time(datetime) do
today = Date.utc_today()
event_date = DateTime.to_date(datetime)
diff_days = Date.diff(today, event_date)
cond do
diff_days == 0 -> Calendar.strftime(datetime, "%H:%M")
diff_days < 7 -> Calendar.strftime(datetime, "%d %b %H:%M")
true -> Calendar.strftime(datetime, "%d %b %Y")
end
end
defp can_submit?(order) do
order.payment_status == "paid" and order.fulfilment_status in ["unfulfilled", "failed"]
end

View File

@@ -148,6 +148,7 @@ defmodule BerrypodWeb.Router do
live "/analytics", Admin.Analytics, :index
live "/orders", Admin.Orders, :index
live "/orders/:id", Admin.OrderShow, :show
live "/activity", Admin.Activity, :index
live "/products", Admin.Products, :index
live "/products/:id", Admin.ProductShow, :show
live "/providers", Admin.Providers.Index, :index