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 --%> +
    + +
    +
    + <.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} - - -
    +
    +
    + <.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. +
    +
      +
    1. +
      +
      +

      {entry.message}

      + +
      +
    2. +
    + """ + 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)