**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 :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 |
**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:
- **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).