berrypod/docs/plans/activity-log.md
jamey edef628214 tidy docs: condense progress, trim readme, mark plan statuses
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:15:18 +00:00

11 KiB
Raw Permalink Blame History

Activity log & order timeline

Status: Complete Tasks: #8992 Tier: 3.5 (Business tools)

Goal

One table. Two views.

Order timeline — on the order detail page, replace the static field cards with a chronological feed showing everything that happened to that order: payment confirmed, email sent, submitted to provider, in production, shipped (with tracking), delivery confirmed, any errors and retries. The shop owner can see the complete lifecycle without digging through separate panels.

Global activity feed/admin/activity — a reverse-chronological stream of all meaningful events across the system: orders, syncs, emails, abandoned carts. Filterable. Errors and warnings bubble up as "needs attention" with a count badge on the admin nav.

Same data source for both. No separate "notifications" table. The activity log is the notifications.


Schema

create table(:activity_log, primary_key: false) do
  add :id, :binary_id, primary_key: true
  add :event_type, :string, null: false     # "order.shipped", "email.confirmation_sent", etc.
  add :level, :string, null: false           # "info", "warning", "error"
  add :order_id, references(:orders, type: :binary_id, on_delete: :nilify_all)
  add :payload, :map                         # JSON snapshot — what was relevant at the time
  add :message, :string, null: false         # human-readable, shown in the UI
  add :resolved_at, :utc_datetime            # nil until acknowledged (for "needs attention")
  add :occurred_at, :utc_datetime, null: false
  timestamps()
end

create index(:activity_log, [:order_id])
create index(:activity_log, [:occurred_at])
create index(:activity_log, [:level, :resolved_at])  # for "needs attention" query

Why occurred_at separate from inserted_at

For most events they'll be the same. But if a webhook arrives late (Stripe retries, provider delays) the occurred_at reflects when the event actually happened (from the webhook timestamp), while inserted_at is when we recorded it. The timeline sorts by occurred_at.


Event taxonomy

Order events (all carry order_id)

Event type Level When fired Message template
order.created info Stripe checkout.session.completed webhook "Order placed — £43.00 via Stripe"
order.email.confirmation_sent info After confirmation email sends "Order confirmation sent to customer@example.com"
order.submitted info After successful provider submission "Submitted to Printify (PFY-12345)"
order.submission_failed error On provider submission error "Submission to Printify failed: insufficient stock for M Black"
order.submission_retried info On retry after failure "Submission retried — attempt 2 of 3"
order.production info Provider webhook: in production "In production at Printify"
order.shipped info Provider webhook: shipped "Shipped via DHL — tracking: 1Z999…"
order.email.shipping_sent info After shipping email sends "Shipping notification sent to customer@example.com"
order.delivered info Provider webhook: delivered "Delivered"
order.cancelled warning On cancellation "Order cancelled"
order.refunded info On refund "Refunded £43.00"

System events (no order_id)

Event type Level When fired Message template
sync.started info ProductSyncWorker begins "Product sync started (Printify)"
sync.completed info Sync finishes successfully "Product sync complete — 47 products, 3 updated"
sync.failed error Sync throws an error "Product sync failed: API rate limit exceeded"
abandoned_cart.created info Abandoned cart recorded "Abandoned cart — customer@example.com, £43.00"
abandoned_cart.email_sent info Recovery email sent "Recovery email sent to customer@example.com"
abandoned_cart.recovered info Customer buys after recovery "Cart recovered — order SS-260223-0042 placed"
email.bounced warning Email delivery failure reported "Email bounced: customer@example.com (order confirmation)"

ActivityLog context

defmodule Berrypod.ActivityLog do
  def log_event(event_type, message, opts \\ []) do
    %ActivityLog{}
    |> ActivityLog.changeset(%{
      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()
    })
    |> Repo.insert()
    # Failures are silent — never crash a business-critical path for a log entry
    |> case do
      {:ok, entry} -> {:ok, entry}
      {:error, _} -> :ok
    end
  end

  def list_for_order(order_id) do
    from(a in ActivityLog,
      where: a.order_id == ^order_id,
      order_by: [asc: a.occurred_at]
    ) |> Repo.all()
  end

  def list_recent(opts \\ []) do
    limit = opts[:limit] || 50
    level = opts[:level]

    query = from(a in ActivityLog, order_by: [desc: a.occurred_at], limit: ^limit)
    query = if level, do: where(query, [a], a.level == ^level), else: query
    Repo.all(query)
  end

  def count_needing_attention do
    from(a in ActivityLog,
      where: a.level in ["warning", "error"] and is_nil(a.resolved_at)
    ) |> Repo.aggregate(:count)
  end

  def resolve(id) do
    from(a in ActivityLog, where: a.id == ^id)
    |> Repo.update_all(set: [resolved_at: DateTime.utc_now()])
  end

  def resolve_all_for_order(order_id) do
    from(a in ActivityLog,
      where: a.order_id == ^order_id and is_nil(a.resolved_at)
    ) |> Repo.update_all(set: [resolved_at: DateTime.utc_now()])
  end
end

Key principle: log_event/3 never raises. If the DB insert fails for any reason, the calling code carries on. A business-critical path (processing a Stripe webhook, submitting an order) must not fail because the activity log had an error.


Instrumentation points

Where to add ActivityLog.log_event/3 calls in the existing codebase:

File Event(s) Notes
stripe_webhook_controller.ex order.created Already has the order and amount from the webhook
notifier/order_notifier.ex order.email.confirmation_sent, order.email.shipping_sent After successful send
workers/order_submission_worker.ex order.submitted, order.submission_failed, order.submission_retried On perform success/failure, retry attempt count from job
workers/fulfilment_status_worker.ex (or provider webhooks) order.production, order.shipped, order.delivered, order.cancelled On status change only (guard: don't log if status didn't change)
workers/product_sync_worker.ex sync.started, sync.completed, sync.failed Include counts in payload
workers/abandoned_cart_email_worker.ex (planned) abandoned_cart.email_sent, abandoned_cart.recovered When built

Order timeline UI

Below the line items table on /admin/orders/:id, a new "Activity" card:

● 14:23  Order placed — £43.00 via Stripe
● 14:24  Order confirmation sent to customer@example.com
● 14:25  Submitted to Printify (PFY-78234)
● 14:26  In production at Printify
● 16 Feb  Shipped via DHL — tracking: 1Z999AA10123456784
● 16 Feb  Shipping notification sent to customer@example.com
● 18 Feb  Delivered

For an order with a problem:

● 10:00  Order placed — £28.00 via Stripe
● 10:01  Order confirmation sent to customer@example.com
⚠ 10:02  Submission to Printify failed: variant out of stock
● 10:07  Retrying — attempt 2 of 3
● 10:07  Submitted to Printify (PFY-78299)
● 10:08  In production at Printify

Each entry shows a dot (●) or warning icon (⚠) and the timestamp + message. No separate fields to cross-reference. The whole story is here.

The timeline is loaded once on mount (no streaming needed — order events are append-only and infrequent). A phx-click="refresh" button for orders that are still in progress.


Global activity feed — /admin/activity

Reverse-chronological list of all events. Two tabs:

  • All activity — everything, newest first, paginated (50 per page)
  • Needs attention — errors and warnings where resolved_at is nil

Each row:

  • Icon (coloured by level — green info, amber warning, red error)
  • Event type label
  • Message
  • Relative time ("2 hours ago")
  • Link to related order if order_id present
  • "Resolve" button for warnings/errors

"Needs attention" badge on admin nav:

A small count badge on the Activity nav item when count_needing_attention/0 > 0. Polled on a Process.send_after timer (every 60 seconds) — not a PubSub subscription (no need for real-time here).

[⚑ Activity  3]

90-day pruning

Oban cron job, runs nightly:

def perform(_job) do
  cutoff = DateTime.add(DateTime.utc_now(), -90, :day)
  Repo.delete_all(from a in ActivityLog, where: a.inserted_at < ^cutoff)
  :ok
end

90 days is plenty for business purposes. Resolved errors and successful info events don't need to be kept indefinitely.


What this replaces / improves

Currently the order detail has:

  • A "Fulfilment" card with scattered timestamps (submitted_at, shipped_at, delivered_at) as key/value pairs
  • A fulfilment_error field that overwrites itself (only the last error is visible)

After this:

  • All events are preserved — if a submission failed and was retried, you see both entries
  • Errors are never lost — fulfilment_error on the order is still kept for quick status, but the full history is in the log
  • Emails are visible — currently you have no way to know if the confirmation email actually sent successfully

Files to create/modify

  • Migration — activity_log table
  • lib/berrypod/activity_log.ex — schema
  • lib/berrypod/activity_log/ or lib/berrypod/activity_log.ex — context functions
  • lib/berrypod_web/live/admin/activity.ex — new LiveView for global feed
  • lib/berrypod_web/live/admin/order_show.ex — load and render order timeline
  • Admin nav — add Activity link with "needs attention" badge
  • Router — /admin/activity route
  • Instrumentation in: stripe_webhook_controller.ex, notifier/order_notifier.ex, workers/order_submission_worker.ex, workers/fulfilment_status_worker.ex, workers/product_sync_worker.ex
  • Oban crontab config — add ActivityLogPruneWorker

Tasks

# Task Est
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
90 Instrument existing event points — stripe webhook, OrderNotifier, OrderSubmissionWorker, fulfilment status, ProductSyncWorker 1.5h
91 Order timeline component + add to order detail page (/admin/orders/:id) 1.5h
92 Global /admin/activity LiveView — all activity feed, "needs attention" tab, "resolve" action, count badge on admin nav 2h