# Activity log & order timeline

> Status: Complete
> Tasks: #89–92
> 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

```elixir
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

```elixir
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:

```elixir
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 |
