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

@@ -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