add activity log with order timeline and global feed
All checks were successful
deploy / deploy (push) Successful in 4m22s
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:
49
test/berrypod/activity_log/oban_telemetry_handler_test.exs
Normal file
49
test/berrypod/activity_log/oban_telemetry_handler_test.exs
Normal 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
|
||||
299
test/berrypod/activity_log_test.exs
Normal file
299
test/berrypod/activity_log_test.exs
Normal 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
|
||||
Reference in New Issue
Block a user