Canonical: all shop pages now assign og_url (reusing the existing og:url assign), which the layout renders as <link rel="canonical">. Collection pages strip the sort param so ?sort=price_asc doesn't create a duplicate canonical. robots.txt: dynamic controller disallows /admin/, /api/, /users/, /webhooks/, /checkout/. Removed robots.txt from static_paths so it goes through the router instead of Plug.Static. sitemap.xml: auto-generated from all visible products + categories + static pages, served as application/xml. 8 tests. Also updates PROGRESS.md: marks tasks 55, 58, 59, 61, 62 as done. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
11 KiB
Activity log & order timeline
Status: Planned Tasks: #89–92 in PROGRESS.md 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_atis 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_idpresent - "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_errorfield 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_erroron 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_logtable lib/berrypod/activity_log.ex— schemalib/berrypod/activity_log/orlib/berrypod/activity_log.ex— context functionslib/berrypod_web/live/admin/activity.ex— new LiveView for global feedlib/berrypod_web/live/admin/order_show.ex— load and render order timeline- Admin nav — add Activity link with "needs attention" badge
- Router —
/admin/activityroute - 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 |