diff --git a/PROGRESS.md b/PROGRESS.md
index 498cbba..94fbf78 100644
--- a/PROGRESS.md
+++ b/PROGRESS.md
@@ -9,7 +9,7 @@
- Image optimization pipeline (AVIF/WebP/JPEG responsive variants)
- Shop pages (home, collections, products, cart, about, contact, error, delivery, privacy, terms)
- Mobile-first design with bottom navigation
-- 1455 tests passing, 100% PageSpeed score
+- 1679 tests passing, 100% PageSpeed score
- SQLite production tuning (IMMEDIATE transactions, mmap, WAL journal limit)
- Variant selector with color swatches and size buttons
- Session-based cart with real variant data (add/remove/quantity, cross-tab sync)
@@ -23,6 +23,7 @@
- Transactional emails (order confirmation, shipping notification)
- Demo content polished and ready for production
- Privacy-first analytics with comparison mode (period deltas on stat cards)
+- Activity log with real-time global feed, order timeline, contextual retry buttons, nav badge
**Tier 1 MVP complete.** CI pipeline done. Hosting & deployment done (including observability). PageSpeed CI done (99-100 mobile, 97+ desktop). Usability fixes done. Shipping costs at checkout done. Per-colour product images with gallery filtering done (both providers). Printful integration complete (sync, orders, shipping, webhooks, mockup enrichment, catalog colours). CSS migration Phases 0-7 complete — project is fully Tailwind-free (hand-written CSS, 9.8 KB gzipped shop, 17.8 KB gzipped admin). Setup and launch readiness complete — `/setup` onboarding page, dashboard launch checklist, provider registry, provider-agnostic setup status.
@@ -121,12 +122,12 @@ Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [admin-font-loading.m
| ~~79~~ | ~~Auto-redirect on slug change — hook into `upsert_product/2` to detect old/new slug diff~~ | 78 | 45m | done |
| ~~80~~ | ~~Analytics-powered 404 monitoring — query analytics on 404, FTS5 auto-resolve, broken URLs queue~~ | 78 | 2h | done |
| ~~81~~ | ~~Admin redirects UI — active redirects, broken URLs (sorted by prior traffic), manual create~~ | 78 | 2h | done |
-| 82 | Dead link monitoring — validate stored links (internal via Phoenix.Router, external via async Oban HEAD), event-driven on product changes, admin dead links tab | page editor | 2.5h | deferred |
+| ~~82~~ | ~~Dead link monitoring — scan page blocks + nav items for broken outgoing links (internal via DB lookup, external via HTTP HEAD), daily Oban cron, event-driven on page save, admin dead links tab with re-check/ignore/source links~~ | page editor | 2.5h | done |
| | **Activity log & order timeline** ([plan](docs/plans/activity-log.md)) | | | |
-| 89 | `activity_log` schema + migration + `ActivityLog` context (`log_event/3`, `list_for_order/1`, `list_recent/1`, `count_needing_attention/0`, `resolve/1`) | — | 1.5h | planned |
-| 90 | Instrument existing event points — stripe webhook, OrderNotifier, OrderSubmissionWorker, fulfilment status, ProductSyncWorker | 89 | 1.5h | planned |
-| 91 | Order timeline component on `/admin/orders/:id` — chronological feed replacing scattered field cards | 89 | 1.5h | planned |
-| 92 | Global `/admin/activity` LiveView — all activity + "needs attention" tab, resolve action, count badge on admin nav | 89 | 2h | planned |
+| ~~89~~ | ~~`activity_log` schema + migration + `ActivityLog` context (`log_event/3`, `list_for_order/1`, `list_recent/1`, `count_needing_attention/0`, `resolve/1`)~~ | — | 1.5h | done |
+| ~~90~~ | ~~Instrument existing event points — stripe webhook, OrderNotifier, OrderSubmissionWorker, fulfilment status, ProductSyncWorker~~ | 89 | 1.5h | done |
+| ~~91~~ | ~~Order timeline component on `/admin/orders/:id` — chronological feed replacing scattered field cards~~ | 89 | 1.5h | done |
+| ~~92~~ | ~~Global `/admin/activity` LiveView — all activity + "needs attention" tab, resolve action, count badge on admin nav + contextual retry buttons~~ | 89 | 2h | done |
| | **Admin & page editor UX polish** ([plan](docs/plans/admin-ux-polish.md)) | | | |
| ~~103~~ | ~~Unsaved changes warning — `beforeunload` + LiveView nav guard on page editor~~ | — | 30m | done |
| ~~104~~ | ~~Block descriptions in picker — add subtitle text to each block type~~ | — | 45m | done |
diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css
index 3e044b3..447d384 100644
--- a/assets/css/admin/components.css
+++ b/assets/css/admin/components.css
@@ -2527,4 +2527,113 @@
}
}
+/* -- Timeline -- */
+
+.admin-timeline {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ position: relative;
+}
+
+.admin-timeline::before {
+ content: "";
+ position: absolute;
+ left: 0.4375rem;
+ top: 0.75rem;
+ bottom: 0.75rem;
+ width: 1px;
+ background: var(--t-border-default);
+}
+
+.admin-timeline-item {
+ display: flex;
+ gap: 0.75rem;
+ padding: 0.375rem 0;
+ position: relative;
+}
+
+.admin-timeline-dot {
+ width: 0.875rem;
+ height: 0.875rem;
+ border-radius: 9999px;
+ flex-shrink: 0;
+ margin-top: 0.125rem;
+ border: 2px solid var(--t-surface-base);
+ position: relative;
+ z-index: 1;
+}
+
+.admin-timeline-dot-info {
+ background: var(--t-status-success, #22c55e);
+}
+
+.admin-timeline-dot-warning {
+ background: var(--t-status-warning, #f59e0b);
+}
+
+.admin-timeline-dot-error {
+ background: var(--t-status-error, #ef4444);
+}
+
+.admin-timeline-content {
+ flex: 1;
+ min-width: 0;
+}
+
+.admin-timeline-message {
+ font-size: 0.875rem;
+ line-height: 1.4;
+ margin: 0;
+}
+
+.admin-timeline-time {
+ font-size: 0.75rem;
+ color: color-mix(in oklch, var(--t-text-primary) 50%, transparent);
+}
+
+/* -- Activity feed -- */
+
+.admin-activity-row {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.75rem;
+ padding: 0.625rem 0;
+ border-bottom: 1px solid color-mix(in oklch, var(--t-border-default) 50%, transparent);
+}
+
+.admin-activity-row:last-child {
+ border-bottom: none;
+}
+
+.admin-activity-icon {
+ flex-shrink: 0;
+ margin-top: 0.125rem;
+}
+
+.admin-activity-body {
+ flex: 1;
+ min-width: 0;
+ font-size: 0.875rem;
+ line-height: 1.4;
+}
+
+.admin-activity-type {
+ font-weight: 500;
+ margin-right: 0.25rem;
+}
+
+.admin-activity-meta {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.admin-activity-time {
+ font-size: 0.75rem;
+ color: color-mix(in oklch, var(--t-text-primary) 50%, transparent);
+ white-space: nowrap;
+}
+
} /* @layer admin */
diff --git a/config/config.exs b/config/config.exs
index 8fc2e19..b53a6d7 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -99,7 +99,8 @@ config :berrypod, Oban,
{"0 5 * * 1", Berrypod.Workers.RedirectPrunerWorker},
{"30 3 * * *", Berrypod.Workers.DeadLinkCheckerWorker},
{"0 2 * * *", Berrypod.Newsletter.CleanupWorker},
- {"*/5 * * * *", Berrypod.Newsletter.ScheduledCampaignWorker}
+ {"*/5 * * * *", Berrypod.Newsletter.ScheduledCampaignWorker},
+ {"0 1 * * *", Berrypod.ActivityLog.PruneWorker}
]}
],
queues: [images: 2, sync: 1, checkout: 1, newsletter: 1]
diff --git a/lib/berrypod/activity_log.ex b/lib/berrypod/activity_log.ex
new file mode 100644
index 0000000..1611829
--- /dev/null
+++ b/lib/berrypod/activity_log.ex
@@ -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
diff --git a/lib/berrypod/activity_log/entry.ex b/lib/berrypod/activity_log/entry.ex
new file mode 100644
index 0000000..0984d8c
--- /dev/null
+++ b/lib/berrypod/activity_log/entry.ex
@@ -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
diff --git a/lib/berrypod/activity_log/oban_telemetry_handler.ex b/lib/berrypod/activity_log/oban_telemetry_handler.ex
new file mode 100644
index 0000000..dc3d96c
--- /dev/null
+++ b/lib/berrypod/activity_log/oban_telemetry_handler.ex
@@ -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
diff --git a/lib/berrypod/activity_log/prune_worker.ex b/lib/berrypod/activity_log/prune_worker.ex
new file mode 100644
index 0000000..0b2d872
--- /dev/null
+++ b/lib/berrypod/activity_log/prune_worker.ex
@@ -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
diff --git a/lib/berrypod/application.ex b/lib/berrypod/application.ex
index 5453f09..6cfa387 100644
--- a/lib/berrypod/application.ex
+++ b/lib/berrypod/application.ex
@@ -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,
diff --git a/lib/berrypod/orders/fulfilment_status_worker.ex b/lib/berrypod/orders/fulfilment_status_worker.ex
index 336e52f..7167575 100644
--- a/lib/berrypod/orders/fulfilment_status_worker.ex
+++ b/lib/berrypod/orders/fulfilment_status_worker.ex
@@ -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
diff --git a/lib/berrypod/orders/order_notifier.ex b/lib/berrypod/orders/order_notifier.ex
index 877e140..7079d5f 100644
--- a/lib/berrypod/orders/order_notifier.ex
+++ b/lib/berrypod/orders/order_notifier.ex
@@ -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 ---
diff --git a/lib/berrypod/orders/order_submission_worker.ex b/lib/berrypod/orders/order_submission_worker.ex
index ad0291a..87aa3fb 100644
--- a/lib/berrypod/orders/order_submission_worker.ex
+++ b/lib/berrypod/orders/order_submission_worker.ex
@@ -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
diff --git a/lib/berrypod/sync/product_sync_worker.ex b/lib/berrypod/sync/product_sync_worker.ex
index 16029d5..9f47d9e 100644
--- a/lib/berrypod/sync/product_sync_worker.ex
+++ b/lib/berrypod/sync/product_sync_worker.ex
@@ -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
diff --git a/lib/berrypod_web/admin_layout_hook.ex b/lib/berrypod_web/admin_layout_hook.ex
index 32dd635..4bed023 100644
--- a/lib/berrypod_web/admin_layout_hook.ex
+++ b/lib/berrypod_web/admin_layout_hook.ex
@@ -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
diff --git a/lib/berrypod_web/components/layouts/admin.html.heex b/lib/berrypod_web/components/layouts/admin.html.heex
index 1bab07d..06e50b9 100644
--- a/lib/berrypod_web/components/layouts/admin.html.heex
+++ b/lib/berrypod_web/components/layouts/admin.html.heex
@@ -80,6 +80,20 @@
<.icon name="hero-shopping-bag" class="size-5" /> Orders
+
+ <.link
+ navigate={~p"/admin/activity"}
+ class={admin_nav_active?(@current_path, "/admin/activity")}
+ >
+ <.icon name="hero-bell" class="size-5" /> Activity
+ 0}
+ class="admin-badge admin-badge-sm admin-badge-warning ml-auto"
+ >
+ {@attention_count}
+
+
+
<.link
navigate={~p"/admin/products"}
diff --git a/lib/berrypod_web/controllers/stripe_webhook_controller.ex b/lib/berrypod_web/controllers/stripe_webhook_controller.ex
index 94e4922..bbc941f 100644
--- a/lib/berrypod_web/controllers/stripe_webhook_controller.ex
+++ b/lib/berrypod_web/controllers/stripe_webhook_controller.ex
@@ -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} ->
diff --git a/lib/berrypod_web/live/admin/activity.ex b/lib/berrypod_web/live/admin/activity.ex
new file mode 100644
index 0000000..c478a27
--- /dev/null
+++ b/lib/berrypod_web/live/admin/activity.ex
@@ -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
+
+
+ <%!-- tabs --%>
+
+
+
+
+
+ <%!-- category chips + search --%>
+
+ <.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" />
+
+ <.form for={%{}} phx-submit="search" as={:search} class="flex gap-2">
+
+
+
+
+
+
+ <%!-- entries --%>
+
+
+ No activity to show.
+
+
+
+ <.icon name={activity_icon(entry.level)} class="size-4" />
+
+
+ {format_event_type(entry.event_type)}
+ {entry.message}
+ <.link
+ :if={entry.order_id}
+ navigate={~p"/admin/orders/#{entry.order_id}"}
+ class="text-primary hover:underline text-xs ml-1"
+ >
+ View order →
+
+
+
+
+ <.entry_action entry={entry} />
+
+
+
+
+ <.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"""
+
+ """
+ end
+
+ defp entry_action(
+ %{entry: %{event_type: "order.submission_failed", order_id: order_id}} = assigns
+ )
+ when not is_nil(order_id) do
+ ~H"""
+
+ """
+ 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"""
+
+
+ """
+ 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"""
+
+
+ """
+ end
+
+ defp entry_action(%{entry: %{level: level, resolved_at: nil}} = assigns)
+ when level in ["warning", "error"] do
+ ~H"""
+
+ """
+ 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
diff --git a/lib/berrypod_web/live/admin/order_show.ex b/lib/berrypod_web/live/admin/order_show.ex
index db56135..d225eda 100644
--- a/lib/berrypod_web/live/admin/order_show.ex
+++ b/lib/berrypod_web/live/admin/order_show.ex
@@ -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
- <%!-- fulfilment --%>
+ <%!-- timeline --%>
-
Fulfilment
+ Timeline
<.fulfilment_badge status={@order.fulfilment_status} />
- <.list>
- <:item :if={@order.provider_order_id} title="Provider order ID">
-
{@order.provider_order_id}
-
- <:item :if={@order.provider_status} title="Provider status">
- {@order.provider_status}
-
- <:item :if={@order.submitted_at} title="Submitted">
- {format_date(@order.submitted_at)}
-
- <:item :if={@order.tracking_number} title="Tracking">
- <%= if @order.tracking_url do %>
-
- {@order.tracking_number}
-
- <% else %>
- {@order.tracking_number}
- <% end %>
-
- <:item :if={@order.shipped_at} title="Shipped">
- {format_date(@order.shipped_at)}
-
- <:item :if={@order.delivered_at} title="Delivered">
- {format_date(@order.delivered_at)}
-
- <:item :if={@order.fulfilment_error} title="Error">
-
{@order.fulfilment_error}
-
-
-
+
Refresh status
+
+ <.icon name="hero-truck-mini" class="size-4 text-base-content/60" />
+
{@order.tracking_number}
+
+ Track shipment →
+
+
+ <.order_timeline entries={@timeline} />
@@ -234,6 +232,43 @@ defmodule BerrypodWeb.Admin.OrderShow do
end
end
+ # ── Components ──
+
+ defp order_timeline(assigns) do
+ ~H"""
+
+ No activity recorded yet.
+
+
+ -
+
+
+
{entry.message}
+
+
+
+
+ """
+ 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
diff --git a/lib/berrypod_web/router.ex b/lib/berrypod_web/router.ex
index e82cdea..08785b2 100644
--- a/lib/berrypod_web/router.ex
+++ b/lib/berrypod_web/router.ex
@@ -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
diff --git a/priv/repo/migrations/20260301133937_create_activity_log.exs b/priv/repo/migrations/20260301133937_create_activity_log.exs
new file mode 100644
index 0000000..5eabad2
--- /dev/null
+++ b/priv/repo/migrations/20260301133937_create_activity_log.exs
@@ -0,0 +1,102 @@
+defmodule Berrypod.Repo.Migrations.CreateActivityLog do
+ use Ecto.Migration
+
+ def up do
+ create table(:activity_log, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+ add :event_type, :string, null: false
+ add :level, :string, null: false, default: "info"
+ add :order_id, references(:orders, type: :binary_id, on_delete: :nilify_all)
+ add :payload, :map, default: %{}
+ add :message, :string, null: false
+ add :resolved_at, :utc_datetime
+ add :occurred_at, :utc_datetime, null: false
+
+ timestamps(type: :utc_datetime)
+ end
+
+ create index(:activity_log, [:order_id])
+ create index(:activity_log, [:occurred_at])
+ create index(:activity_log, [:level, :resolved_at])
+
+ # Backfill from existing orders
+ flush()
+ backfill_from_orders()
+ end
+
+ def down do
+ drop table(:activity_log)
+ end
+
+ defp backfill_from_orders do
+ now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
+
+ # order.created for all paid orders
+ execute("""
+ INSERT INTO activity_log (id, event_type, level, order_id, payload, message, occurred_at, inserted_at, updated_at)
+ SELECT
+ lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('89ab', abs(random()) % 4 + 1, 1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6))),
+ 'order.created',
+ 'info',
+ id,
+ '{}',
+ 'Order placed',
+ inserted_at,
+ '#{now}',
+ '#{now}'
+ FROM orders
+ WHERE payment_status = 'paid'
+ """)
+
+ # order.submitted
+ execute("""
+ INSERT INTO activity_log (id, event_type, level, order_id, payload, message, occurred_at, inserted_at, updated_at)
+ SELECT
+ lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('89ab', abs(random()) % 4 + 1, 1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6))),
+ 'order.submitted',
+ 'info',
+ id,
+ '{}',
+ 'Submitted to provider',
+ submitted_at,
+ '#{now}',
+ '#{now}'
+ FROM orders
+ WHERE submitted_at IS NOT NULL
+ """)
+
+ # order.shipped
+ execute("""
+ INSERT INTO activity_log (id, event_type, level, order_id, payload, message, occurred_at, inserted_at, updated_at)
+ SELECT
+ lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('89ab', abs(random()) % 4 + 1, 1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6))),
+ 'order.shipped',
+ 'info',
+ id,
+ '{}',
+ 'Shipped',
+ shipped_at,
+ '#{now}',
+ '#{now}'
+ FROM orders
+ WHERE shipped_at IS NOT NULL
+ """)
+
+ # order.delivered
+ execute("""
+ INSERT INTO activity_log (id, event_type, level, order_id, payload, message, occurred_at, inserted_at, updated_at)
+ SELECT
+ lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('89ab', abs(random()) % 4 + 1, 1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6))),
+ 'order.delivered',
+ 'info',
+ id,
+ '{}',
+ 'Delivered',
+ delivered_at,
+ '#{now}',
+ '#{now}'
+ FROM orders
+ WHERE delivered_at IS NOT NULL
+ """)
+ end
+end
diff --git a/test/berrypod/activity_log/oban_telemetry_handler_test.exs b/test/berrypod/activity_log/oban_telemetry_handler_test.exs
new file mode 100644
index 0000000..bf65ac4
--- /dev/null
+++ b/test/berrypod/activity_log/oban_telemetry_handler_test.exs
@@ -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
diff --git a/test/berrypod/activity_log_test.exs b/test/berrypod/activity_log_test.exs
new file mode 100644
index 0000000..dc784ee
--- /dev/null
+++ b/test/berrypod/activity_log_test.exs
@@ -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:
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
diff --git a/test/berrypod_web/live/admin/activity_test.exs b/test/berrypod_web/live/admin/activity_test.exs
new file mode 100644
index 0000000..a81dc65
--- /dev/null
+++ b/test/berrypod_web/live/admin/activity_test.exs
@@ -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
diff --git a/test/berrypod_web/live/admin/orders_test.exs b/test/berrypod_web/live/admin/orders_test.exs
index b42ac7d..55a08b9 100644
--- a/test/berrypod_web/live/admin/orders_test.exs
+++ b/test/berrypod_web/live/admin/orders_test.exs
@@ -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)