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,49 @@
defmodule Berrypod.ActivityLog.ObanTelemetryHandlerTest do
use Berrypod.DataCase, async: false
alias Berrypod.ActivityLog
alias Berrypod.ActivityLog.ObanTelemetryHandler
describe "handle_event/4" do
test "logs activity for discarded (exhausted) jobs" do
metadata = %{
state: :discard,
job: %{worker: "Berrypod.Orders.OrderSubmissionWorker", queue: "checkout"},
reason: RuntimeError.exception("something broke")
}
ObanTelemetryHandler.handle_event(
[:oban, :job, :exception],
%{duration: 1000},
metadata,
[]
)
[entry] = ActivityLog.list_recent().items
assert entry.event_type == "job.exhausted"
assert entry.level == "error"
assert entry.message =~ "OrderSubmissionWorker"
assert entry.payload["worker"] == "Berrypod.Orders.OrderSubmissionWorker"
assert entry.payload["queue"] == "checkout"
assert entry.payload["error"] =~ "something broke"
end
test "ignores non-discard states" do
metadata = %{
state: :failure,
job: %{worker: "SomeWorker", queue: "default"},
reason: RuntimeError.exception("temporary failure")
}
ObanTelemetryHandler.handle_event(
[:oban, :job, :exception],
%{duration: 1000},
metadata,
[]
)
assert ActivityLog.list_recent().total_count == 0
end
end
end

View File

@@ -0,0 +1,299 @@
defmodule Berrypod.ActivityLogTest do
use Berrypod.DataCase, async: false
use Oban.Testing, repo: Berrypod.Repo
alias Berrypod.ActivityLog
alias Berrypod.ActivityLog.Entry
import Berrypod.OrdersFixtures
# ── log_event/3 ──
describe "log_event/3" do
test "creates an entry with correct fields" do
{:ok, entry} = ActivityLog.log_event("sync.completed", "Product sync complete")
assert entry.event_type == "sync.completed"
assert entry.message == "Product sync complete"
assert entry.level == "info"
assert entry.occurred_at
end
test "accepts level, order_id, payload, and occurred_at options" do
order = order_fixture(%{payment_status: "paid"})
ts = ~U[2026-02-15 10:00:00Z]
{:ok, entry} =
ActivityLog.log_event("order.submitted", "Submitted to Printify",
level: "warning",
order_id: order.id,
payload: %{provider_order_id: "PFY-123"},
occurred_at: ts
)
assert entry.level == "warning"
assert entry.order_id == order.id
assert entry.payload == %{provider_order_id: "PFY-123"}
assert entry.occurred_at == ts
end
test "defaults level to info and occurred_at to now" do
{:ok, entry} = ActivityLog.log_event("sync.started", "Starting sync")
assert entry.level == "info"
assert DateTime.diff(DateTime.utc_now(), entry.occurred_at, :second) < 2
end
test "never raises on invalid attrs" do
# missing required message (nil)
result = ActivityLog.log_event("test.event", nil)
assert result == :ok
end
test "broadcasts on activity:all topic" do
ActivityLog.subscribe()
{:ok, entry} = ActivityLog.log_event("sync.started", "Starting sync")
assert_receive {:new_activity, ^entry}
end
test "broadcasts on activity:order:<id> when order_id present" do
order = order_fixture(%{payment_status: "paid"})
ActivityLog.subscribe(order.id)
{:ok, entry} =
ActivityLog.log_event("order.submitted", "Submitted", order_id: order.id)
assert_receive {:new_activity, ^entry}
end
test "does not broadcast on order topic when no order_id" do
ActivityLog.subscribe()
{:ok, _entry} = ActivityLog.log_event("sync.started", "Starting sync")
assert_receive {:new_activity, _}
# only the global topic message, no order-specific one
end
end
# ── list_for_order/1 ──
describe "list_for_order/1" do
test "returns entries for a specific order sorted ASC by occurred_at" do
order = order_fixture(%{payment_status: "paid"})
{:ok, _} =
ActivityLog.log_event("order.created", "Order placed",
order_id: order.id,
occurred_at: ~U[2026-02-15 10:00:00Z]
)
{:ok, _} =
ActivityLog.log_event("order.submitted", "Submitted",
order_id: order.id,
occurred_at: ~U[2026-02-15 10:01:00Z]
)
{:ok, _} =
ActivityLog.log_event("order.shipped", "Shipped",
order_id: order.id,
occurred_at: ~U[2026-02-15 12:00:00Z]
)
entries = ActivityLog.list_for_order(order.id)
assert length(entries) == 3
assert Enum.map(entries, & &1.event_type) == ~w(order.created order.submitted order.shipped)
end
test "returns empty list for order with no events" do
assert ActivityLog.list_for_order(Ecto.UUID.generate()) == []
end
test "does not include events from other orders" do
order1 = order_fixture(%{payment_status: "paid"})
order2 = order_fixture(%{payment_status: "paid"})
{:ok, _} = ActivityLog.log_event("order.created", "O1", order_id: order1.id)
{:ok, _} = ActivityLog.log_event("order.created", "O2", order_id: order2.id)
assert length(ActivityLog.list_for_order(order1.id)) == 1
assert length(ActivityLog.list_for_order(order2.id)) == 1
end
end
# ── list_recent/1 ──
describe "list_recent/1" do
test "returns paginated entries newest first" do
for i <- 1..3 do
{:ok, _} =
ActivityLog.log_event("sync.completed", "Sync #{i}",
occurred_at: DateTime.add(~U[2026-02-15 10:00:00Z], i, :minute)
)
end
page = ActivityLog.list_recent()
assert page.total_count == 3
messages = Enum.map(page.items, & &1.message)
assert List.first(messages) == "Sync 3"
assert List.last(messages) == "Sync 1"
end
test "filters by attention tab" do
{:ok, _} = ActivityLog.log_event("sync.completed", "OK")
{:ok, _} = ActivityLog.log_event("sync.failed", "Failed", level: "error")
page = ActivityLog.list_recent(tab: "attention")
assert page.total_count == 1
assert hd(page.items).level == "error"
end
test "filters by orders category" do
{:ok, _} = ActivityLog.log_event("order.created", "Order placed")
{:ok, _} = ActivityLog.log_event("sync.completed", "Sync done")
page = ActivityLog.list_recent(category: "orders")
assert page.total_count == 1
assert hd(page.items).event_type == "order.created"
end
test "filters by syncs category" do
{:ok, _} = ActivityLog.log_event("order.created", "Order placed")
{:ok, _} = ActivityLog.log_event("sync.completed", "Sync done")
page = ActivityLog.list_recent(category: "syncs")
assert page.total_count == 1
assert hd(page.items).event_type == "sync.completed"
end
test "filters by emails category" do
{:ok, _} = ActivityLog.log_event("order.email.confirmation_sent", "Email sent")
{:ok, _} = ActivityLog.log_event("sync.completed", "Sync done")
page = ActivityLog.list_recent(category: "emails")
assert page.total_count == 1
assert hd(page.items).event_type == "order.email.confirmation_sent"
end
test "filters by carts category" do
{:ok, _} = ActivityLog.log_event("abandoned_cart.created", "Cart abandoned")
{:ok, _} = ActivityLog.log_event("sync.completed", "Sync done")
page = ActivityLog.list_recent(category: "carts")
assert page.total_count == 1
assert hd(page.items).event_type == "abandoned_cart.created"
end
test "searches by order number" do
order = order_fixture(%{payment_status: "paid"})
{:ok, _} = ActivityLog.log_event("order.created", "Order placed", order_id: order.id)
{:ok, _} = ActivityLog.log_event("sync.completed", "Sync done")
# Extract enough of the order number to match
search = String.slice(order.order_number, 3, 6)
page = ActivityLog.list_recent(search: search)
assert page.total_count == 1
assert hd(page.items).order_id == order.id
end
end
# ── count_needing_attention/0 ──
describe "count_needing_attention/0" do
test "counts unresolved warnings and errors" do
{:ok, _} = ActivityLog.log_event("sync.failed", "Error", level: "error")
{:ok, _} = ActivityLog.log_event("order.cancelled", "Cancelled", level: "warning")
{:ok, _} = ActivityLog.log_event("sync.completed", "OK")
assert ActivityLog.count_needing_attention() == 2
end
test "returns 0 when all resolved" do
{:ok, entry} = ActivityLog.log_event("sync.failed", "Error", level: "error")
ActivityLog.resolve(entry.id)
assert ActivityLog.count_needing_attention() == 0
end
end
# ── resolve/1 ──
describe "resolve/1" do
test "sets resolved_at on an entry" do
{:ok, entry} = ActivityLog.log_event("sync.failed", "Error", level: "error")
assert is_nil(entry.resolved_at)
{1, _} = ActivityLog.resolve(entry.id)
updated = Repo.get!(Entry, entry.id)
refute is_nil(updated.resolved_at)
end
end
# ── resolve_all_for_order/1 ──
describe "resolve_all_for_order/1" do
test "resolves all unresolved entries for an order" do
order = order_fixture(%{payment_status: "paid"})
{:ok, _} =
ActivityLog.log_event("order.submission_failed", "Failed",
level: "error",
order_id: order.id
)
{:ok, _} =
ActivityLog.log_event("order.cancelled", "Cancelled",
level: "warning",
order_id: order.id
)
{2, _} = ActivityLog.resolve_all_for_order(order.id)
entries = ActivityLog.list_for_order(order.id)
assert Enum.all?(entries, fn e -> not is_nil(e.resolved_at) end)
end
end
# ── PruneWorker ──
describe "PruneWorker" do
test "deletes entries older than 90 days" do
old_time = DateTime.utc_now() |> DateTime.add(-91, :day) |> DateTime.truncate(:second)
recent_time = DateTime.utc_now() |> DateTime.add(-1, :day) |> DateTime.truncate(:second)
# Insert old entry directly to control inserted_at
Repo.insert!(%Entry{
event_type: "sync.completed",
message: "Old sync",
level: "info",
occurred_at: old_time,
inserted_at: old_time,
updated_at: old_time
})
Repo.insert!(%Entry{
event_type: "sync.completed",
message: "Recent sync",
level: "info",
occurred_at: recent_time,
inserted_at: recent_time,
updated_at: recent_time
})
assert Repo.aggregate(Entry, :count) == 2
:ok = perform_job(Berrypod.ActivityLog.PruneWorker, %{})
entries = Repo.all(Entry)
assert length(entries) == 1
assert hd(entries).message == "Recent sync"
end
end
end

View File

@@ -0,0 +1,169 @@
defmodule BerrypodWeb.Admin.ActivityTest do
use BerrypodWeb.ConnCase, async: false
import Phoenix.LiveViewTest
import Berrypod.AccountsFixtures
import Berrypod.OrdersFixtures
alias Berrypod.ActivityLog
setup do
user = user_fixture()
%{user: user}
end
describe "unauthenticated" do
test "redirects to login", %{conn: conn} do
{:error, redirect} = live(conn, ~p"/admin/activity")
assert {:redirect, %{to: path}} = redirect
assert path == ~p"/users/log-in"
end
end
describe "activity page" do
setup %{conn: conn, user: user} do
conn = log_in_user(conn, user)
%{conn: conn}
end
test "renders empty state", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/activity")
assert html =~ "Activity"
assert html =~ "No activity to show"
end
test "renders activity entries", %{conn: conn} do
ActivityLog.log_event("sync.completed", "Product sync finished")
ActivityLog.log_event("order.created", "Order placed")
{:ok, _view, html} = live(conn, ~p"/admin/activity")
assert html =~ "Product sync finished"
assert html =~ "Order placed"
end
test "links to order when order_id present", %{conn: conn} do
order = order_fixture(payment_status: "paid")
ActivityLog.log_event("order.created", "Order placed", order_id: order.id)
{:ok, _view, html} = live(conn, ~p"/admin/activity")
assert html =~ "View order"
assert html =~ ~p"/admin/orders/#{order}"
end
test "filters by attention tab", %{conn: conn} do
ActivityLog.log_event("sync.completed", "Sync OK")
ActivityLog.log_event("order.submission_failed", "Submission failed", level: "error")
{:ok, view, _html} = live(conn, ~p"/admin/activity")
html = render_click(view, "tab", %{"tab" => "attention"})
assert html =~ "Submission failed"
refute html =~ "Sync OK"
end
test "filters by category", %{conn: conn} do
ActivityLog.log_event("sync.completed", "Sync done")
ActivityLog.log_event("order.created", "Order placed")
{:ok, view, _html} = live(conn, ~p"/admin/activity")
html = render_click(view, "category", %{"category" => "syncs"})
assert html =~ "Sync done"
refute html =~ "Order placed"
end
test "searches by order number", %{conn: conn} do
order = order_fixture(payment_status: "paid")
_other_order = order_fixture(payment_status: "paid")
ActivityLog.log_event("order.created", "First order", order_id: order.id)
ActivityLog.log_event("sync.completed", "Sync done")
{:ok, view, _html} = live(conn, ~p"/admin/activity")
html =
render_submit(view, "search", %{"search" => %{"query" => order.order_number}})
assert html =~ "First order"
refute html =~ "Sync done"
end
test "dismisses a warning entry", %{conn: conn} do
{:ok, entry} =
ActivityLog.log_event("order.cancelled", "Order cancelled", level: "warning")
{:ok, view, html} = live(conn, ~p"/admin/activity?tab=attention")
assert html =~ "Order cancelled"
assert html =~ "Dismiss"
render_click(view, "resolve", %{"id" => entry.id})
{:ok, _view, html} = live(conn, ~p"/admin/activity?tab=attention")
refute html =~ "Order cancelled"
end
test "shows retry submission button for failed orders", %{conn: conn} do
order = order_fixture(payment_status: "paid")
ActivityLog.log_event("order.submission_failed", "Submission failed",
level: "error",
order_id: order.id
)
{:ok, _view, html} = live(conn, ~p"/admin/activity?tab=attention")
assert html =~ "Retry submission"
end
test "shows retry sync button for failed syncs", %{conn: conn} do
ActivityLog.log_event("sync.failed", "Sync failed",
level: "error",
payload: %{connection_id: Ecto.UUID.generate()}
)
{:ok, _view, html} = live(conn, ~p"/admin/activity?tab=attention")
assert html =~ "Retry sync"
end
test "updates via PubSub", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/activity")
ActivityLog.log_event("sync.started", "Sync kicked off")
html = render(view)
assert html =~ "Sync kicked off"
end
end
describe "nav badge" do
setup %{conn: conn, user: user} do
conn = log_in_user(conn, user)
%{conn: conn}
end
test "shows badge when attention items exist", %{conn: conn} do
ActivityLog.log_event("order.submission_failed", "Something broke", level: "error")
{:ok, _view, html} = live(conn, ~p"/admin/activity")
# The nav badge should show the count
assert html =~ "admin-badge-warning"
end
test "hides badge when no attention items", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/activity")
# No badge should be rendered
refute html =~ "admin-badge-warning"
end
end
end

View File

@@ -116,12 +116,12 @@ defmodule BerrypodWeb.Admin.OrdersTest do
live(conn, ~p"/admin/orders/#{fake_id}")
end
test "shows fulfilment card", %{conn: conn} do
test "shows timeline card", %{conn: conn} do
order = order_fixture(payment_status: "paid")
{:ok, _view, html} = live(conn, ~p"/admin/orders/#{order}")
assert html =~ "Fulfilment"
assert html =~ "Timeline"
assert html =~ "unfulfilled"
end
@@ -172,6 +172,49 @@ defmodule BerrypodWeb.Admin.OrdersTest do
end
end
describe "order timeline" do
setup %{conn: conn, user: user} do
conn = log_in_user(conn, user)
%{conn: conn}
end
test "renders timeline entries for an order", %{conn: conn} do
order = order_fixture(payment_status: "paid")
Berrypod.ActivityLog.log_event("order.created", "Order placed", order_id: order.id)
Berrypod.ActivityLog.log_event("order.submitted", "Submitted to provider",
order_id: order.id
)
{:ok, _view, html} = live(conn, ~p"/admin/orders/#{order}")
assert html =~ "Order placed"
assert html =~ "Submitted to provider"
end
test "shows empty state when no timeline entries", %{conn: conn} do
order = order_fixture(payment_status: "paid")
{:ok, _view, html} = live(conn, ~p"/admin/orders/#{order}")
assert html =~ "No activity recorded yet"
end
test "updates timeline via PubSub", %{conn: conn} do
order = order_fixture(payment_status: "paid")
{:ok, view, _html} = live(conn, ~p"/admin/orders/#{order}")
Berrypod.ActivityLog.log_event("order.submitted", "Submitted to provider",
order_id: order.id
)
html = render(view)
assert html =~ "Submitted to provider"
end
end
describe "order list fulfilment column" do
setup %{conn: conn, user: user} do
conn = log_in_user(conn, user)