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>
408 lines
12 KiB
Elixir
408 lines
12 KiB
Elixir
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 →
|
|
</.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
|