add activity log with order timeline and global feed
All checks were successful
deploy / deploy (push) Successful in 4m22s
All checks were successful
deploy / deploy (push) Successful in 4m22s
Single activity_log table powering two views: chronological timeline on each order detail page (replacing the old fulfilment card) and a global feed at /admin/activity with tabs, category filters, search, and pagination. Real-time via PubSub — new entries appear instantly, nav badge updates across all admin pages. Instrumented across all event points: Stripe webhooks, order notifier, submission worker, fulfilment status worker, product sync worker, and Oban exhausted-job telemetry. Contextual action buttons (retry submission, retry sync, dismiss) with Oban unique constraints to prevent double-enqueue. 90-day pruning via cron. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b235219aee
commit
580a7203c9
13
PROGRESS.md
13
PROGRESS.md
@ -9,7 +9,7 @@
|
|||||||
- Image optimization pipeline (AVIF/WebP/JPEG responsive variants)
|
- Image optimization pipeline (AVIF/WebP/JPEG responsive variants)
|
||||||
- Shop pages (home, collections, products, cart, about, contact, error, delivery, privacy, terms)
|
- Shop pages (home, collections, products, cart, about, contact, error, delivery, privacy, terms)
|
||||||
- Mobile-first design with bottom navigation
|
- 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)
|
- SQLite production tuning (IMMEDIATE transactions, mmap, WAL journal limit)
|
||||||
- Variant selector with color swatches and size buttons
|
- Variant selector with color swatches and size buttons
|
||||||
- Session-based cart with real variant data (add/remove/quantity, cross-tab sync)
|
- Session-based cart with real variant data (add/remove/quantity, cross-tab sync)
|
||||||
@ -23,6 +23,7 @@
|
|||||||
- Transactional emails (order confirmation, shipping notification)
|
- Transactional emails (order confirmation, shipping notification)
|
||||||
- Demo content polished and ready for production
|
- Demo content polished and ready for production
|
||||||
- Privacy-first analytics with comparison mode (period deltas on stat cards)
|
- 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.
|
**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 |
|
| ~~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 |
|
| ~~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 |
|
| ~~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)) | | | |
|
| | **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 |
|
| ~~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 | planned |
|
| ~~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 | planned |
|
| ~~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 | 89 | 2h | planned |
|
| ~~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)) | | | |
|
| | **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 |
|
| ~~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 |
|
| ~~104~~ | ~~Block descriptions in picker — add subtitle text to each block type~~ | — | 45m | done |
|
||||||
|
|||||||
@ -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 */
|
} /* @layer admin */
|
||||||
|
|||||||
@ -99,7 +99,8 @@ config :berrypod, Oban,
|
|||||||
{"0 5 * * 1", Berrypod.Workers.RedirectPrunerWorker},
|
{"0 5 * * 1", Berrypod.Workers.RedirectPrunerWorker},
|
||||||
{"30 3 * * *", Berrypod.Workers.DeadLinkCheckerWorker},
|
{"30 3 * * *", Berrypod.Workers.DeadLinkCheckerWorker},
|
||||||
{"0 2 * * *", Berrypod.Newsletter.CleanupWorker},
|
{"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]
|
queues: [images: 2, sync: 1, checkout: 1, newsletter: 1]
|
||||||
|
|||||||
175
lib/berrypod/activity_log.ex
Normal file
175
lib/berrypod/activity_log.ex
Normal file
@ -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
|
||||||
36
lib/berrypod/activity_log/entry.ex
Normal file
36
lib/berrypod/activity_log/entry.ex
Normal file
@ -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
|
||||||
35
lib/berrypod/activity_log/oban_telemetry_handler.ex
Normal file
35
lib/berrypod/activity_log/oban_telemetry_handler.ex
Normal file
@ -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
|
||||||
26
lib/berrypod/activity_log/prune_worker.ex
Normal file
26
lib/berrypod/activity_log/prune_worker.ex
Normal file
@ -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
|
||||||
@ -10,6 +10,7 @@ defmodule Berrypod.Application do
|
|||||||
# Create ETS table here so the supervisor process owns it (lives forever).
|
# Create ETS table here so the supervisor process owns it (lives forever).
|
||||||
# The Task below only warms it with data from the DB.
|
# The Task below only warms it with data from the DB.
|
||||||
Berrypod.Redirects.create_table()
|
Berrypod.Redirects.create_table()
|
||||||
|
Berrypod.ActivityLog.ObanTelemetryHandler.attach()
|
||||||
|
|
||||||
children = [
|
children = [
|
||||||
BerrypodWeb.Telemetry,
|
BerrypodWeb.Telemetry,
|
||||||
|
|||||||
@ -9,7 +9,7 @@ defmodule Berrypod.Orders.FulfilmentStatusWorker do
|
|||||||
|
|
||||||
use Oban.Worker, queue: :sync, max_attempts: 1
|
use Oban.Worker, queue: :sync, max_attempts: 1
|
||||||
|
|
||||||
alias Berrypod.Orders
|
alias Berrypod.{ActivityLog, Orders}
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
@ -38,6 +38,8 @@ defmodule Berrypod.Orders.FulfilmentStatusWorker do
|
|||||||
Logger.info(
|
Logger.info(
|
||||||
"Order #{order.order_number} status: #{order.fulfilment_status} → #{updated.fulfilment_status}"
|
"Order #{order.order_number} status: #{order.fulfilment_status} → #{updated.fulfilment_status}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
log_status_change(order, updated)
|
||||||
end
|
end
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
@ -46,4 +48,42 @@ defmodule Berrypod.Orders.FulfilmentStatusWorker do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|||||||
@ -7,7 +7,7 @@ defmodule Berrypod.Orders.OrderNotifier do
|
|||||||
|
|
||||||
import Swoosh.Email
|
import Swoosh.Email
|
||||||
|
|
||||||
alias Berrypod.Cart
|
alias Berrypod.{ActivityLog, Cart}
|
||||||
alias Berrypod.Mailer
|
alias Berrypod.Mailer
|
||||||
|
|
||||||
require Logger
|
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
|
end
|
||||||
|
|
||||||
@doc """
|
@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
|
end
|
||||||
|
|
||||||
@doc """
|
@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
|
end
|
||||||
|
|
||||||
# --- Private ---
|
# --- Private ---
|
||||||
|
|||||||
@ -7,14 +7,14 @@ defmodule Berrypod.Orders.OrderSubmissionWorker do
|
|||||||
Retries up to 3 times with backoff for transient failures.
|
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
|
require Logger
|
||||||
|
|
||||||
@impl Oban.Worker
|
@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
|
case Orders.get_order(order_id) do
|
||||||
nil ->
|
nil ->
|
||||||
Logger.warning("Order submission: order #{order_id} not found")
|
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})"
|
"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
|
:ok
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
Logger.error("Order #{order.order_number} submission failed: #{inspect(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}
|
{:error, reason}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -15,9 +15,9 @@ defmodule Berrypod.Sync.ProductSyncWorker do
|
|||||||
* `provider_connection_id` - The ID of the provider connection to sync
|
* `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.Products.ProviderConnection
|
||||||
alias Berrypod.Providers.Provider
|
alias Berrypod.Providers.Provider
|
||||||
alias Berrypod.Sync.ImageDownloadWorker
|
alias Berrypod.Sync.ImageDownloadWorker
|
||||||
@ -66,6 +66,11 @@ defmodule Berrypod.Sync.ProductSyncWorker do
|
|||||||
|
|
||||||
defp sync_products(conn) do
|
defp sync_products(conn) do
|
||||||
Logger.info("Starting product sync for #{conn.provider_type} (#{conn.id})")
|
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")
|
Products.update_sync_status(conn, "syncing")
|
||||||
broadcast_sync(conn.id, {:sync_status, "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"
|
"#{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)
|
# Enqueue mockup enrichment for Printful products (extra angle images)
|
||||||
if conn.provider_type == "printful" do
|
if conn.provider_type == "printful" do
|
||||||
enqueue_mockup_enrichment(conn, results)
|
enqueue_mockup_enrichment(conn, results)
|
||||||
@ -116,6 +133,16 @@ defmodule Berrypod.Sync.ProductSyncWorker do
|
|||||||
else
|
else
|
||||||
{:error, reason} = error ->
|
{:error, reason} = error ->
|
||||||
Logger.error("Product sync failed for #{conn.provider_type}: #{inspect(reason)}")
|
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")
|
Products.update_sync_status(conn, "failed")
|
||||||
broadcast_sync(conn.id, {:sync_status, "failed"})
|
broadcast_sync(conn.id, {:sync_status, "failed"})
|
||||||
error
|
error
|
||||||
|
|||||||
@ -5,7 +5,7 @@ defmodule BerrypodWeb.AdminLayoutHook do
|
|||||||
"""
|
"""
|
||||||
import Phoenix.Component
|
import Phoenix.Component
|
||||||
|
|
||||||
alias Berrypod.Settings
|
alias Berrypod.{ActivityLog, Settings}
|
||||||
alias Berrypod.Theme.{CSSCache, CSSGenerator}
|
alias Berrypod.Theme.{CSSCache, CSSGenerator}
|
||||||
|
|
||||||
def on_mount(:assign_current_path, _params, _session, socket) do
|
def on_mount(:assign_current_path, _params, _session, socket) do
|
||||||
@ -22,6 +22,8 @@ defmodule BerrypodWeb.AdminLayoutHook do
|
|||||||
css
|
css
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if Phoenix.LiveView.connected?(socket), do: ActivityLog.subscribe()
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:current_path, "")
|
|> assign(:current_path, "")
|
||||||
@ -29,6 +31,7 @@ defmodule BerrypodWeb.AdminLayoutHook do
|
|||||||
|> assign(:email_configured, Berrypod.Mailer.email_configured?())
|
|> assign(:email_configured, Berrypod.Mailer.email_configured?())
|
||||||
|> assign(:theme_settings, theme_settings)
|
|> assign(:theme_settings, theme_settings)
|
||||||
|> assign(:generated_css, generated_css)
|
|> assign(:generated_css, generated_css)
|
||||||
|
|> assign(:attention_count, ActivityLog.count_needing_attention())
|
||||||
|> Phoenix.LiveView.attach_hook(:set_current_path, :handle_params, fn _params,
|
|> Phoenix.LiveView.attach_hook(:set_current_path, :handle_params, fn _params,
|
||||||
uri,
|
uri,
|
||||||
socket ->
|
socket ->
|
||||||
@ -37,6 +40,13 @@ defmodule BerrypodWeb.AdminLayoutHook do
|
|||||||
|> assign(:current_path, URI.parse(uri).path)
|
|> assign(:current_path, URI.parse(uri).path)
|
||||||
|> assign(:site_live, Settings.site_live?())}
|
|> assign(:site_live, Settings.site_live?())}
|
||||||
end)
|
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}
|
{:cont, socket}
|
||||||
end
|
end
|
||||||
|
|||||||
@ -80,6 +80,20 @@
|
|||||||
<.icon name="hero-shopping-bag" class="size-5" /> Orders
|
<.icon name="hero-shopping-bag" class="size-5" /> Orders
|
||||||
</.link>
|
</.link>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<.link
|
||||||
|
navigate={~p"/admin/activity"}
|
||||||
|
class={admin_nav_active?(@current_path, "/admin/activity")}
|
||||||
|
>
|
||||||
|
<.icon name="hero-bell" class="size-5" /> Activity
|
||||||
|
<span
|
||||||
|
:if={@attention_count > 0}
|
||||||
|
class="admin-badge admin-badge-sm admin-badge-warning ml-auto"
|
||||||
|
>
|
||||||
|
{@attention_count}
|
||||||
|
</span>
|
||||||
|
</.link>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<.link
|
<.link
|
||||||
navigate={~p"/admin/products"}
|
navigate={~p"/admin/products"}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
defmodule BerrypodWeb.StripeWebhookController do
|
defmodule BerrypodWeb.StripeWebhookController do
|
||||||
use BerrypodWeb, :controller
|
use BerrypodWeb, :controller
|
||||||
|
|
||||||
alias Berrypod.Orders
|
alias Berrypod.{ActivityLog, Orders}
|
||||||
alias Berrypod.Orders.{OrderNotifier, OrderSubmissionWorker}
|
alias Berrypod.Orders.{OrderNotifier, OrderSubmissionWorker}
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
@ -69,6 +69,17 @@ defmodule BerrypodWeb.StripeWebhookController do
|
|||||||
{:order_paid, order}
|
{: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)
|
OrderNotifier.deliver_order_confirmation(order)
|
||||||
|
|
||||||
# Submit to fulfilment provider
|
# Submit to fulfilment provider
|
||||||
@ -138,6 +149,14 @@ defmodule BerrypodWeb.StripeWebhookController do
|
|||||||
case Orders.create_abandoned_cart(attrs) do
|
case Orders.create_abandoned_cart(attrs) do
|
||||||
{:ok, cart} ->
|
{:ok, cart} ->
|
||||||
Berrypod.Orders.AbandonedCartEmailWorker.enqueue(cart.id)
|
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")
|
Logger.info("Abandoned cart recorded for #{email}, recovery email scheduled")
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
|
|||||||
407
lib/berrypod_web/live/admin/activity.ex
Normal file
407
lib/berrypod_web/live/admin/activity.ex
Normal file
@ -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
|
||||||
|
</.header>
|
||||||
|
|
||||||
|
<%!-- tabs --%>
|
||||||
|
<div class="flex gap-2 mt-6 mb-4 flex-wrap">
|
||||||
|
<button
|
||||||
|
phx-click="tab"
|
||||||
|
phx-value-tab="all"
|
||||||
|
class={[
|
||||||
|
"admin-btn admin-btn-sm",
|
||||||
|
@tab == "all" && "admin-btn-primary",
|
||||||
|
@tab != "all" && "admin-btn-ghost"
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
phx-click="tab"
|
||||||
|
phx-value-tab="attention"
|
||||||
|
class={[
|
||||||
|
"admin-btn admin-btn-sm",
|
||||||
|
@tab == "attention" && "admin-btn-primary",
|
||||||
|
@tab != "attention" && "admin-btn-ghost"
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Needs attention
|
||||||
|
<span
|
||||||
|
:if={@attention_count > 0}
|
||||||
|
class="admin-badge admin-badge-sm admin-badge-warning ml-1"
|
||||||
|
>
|
||||||
|
{@attention_count}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- category chips + search --%>
|
||||||
|
<div class="flex flex-wrap items-center gap-2 mb-4">
|
||||||
|
<.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" />
|
||||||
|
<div class="ml-auto">
|
||||||
|
<.form for={%{}} phx-submit="search" as={:search} class="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="search[query]"
|
||||||
|
value={@search}
|
||||||
|
placeholder="Search by order number"
|
||||||
|
class="admin-input admin-input-sm"
|
||||||
|
/>
|
||||||
|
<button type="submit" class="admin-btn admin-btn-sm admin-btn-ghost">
|
||||||
|
<.icon name="hero-magnifying-glass-mini" class="size-4" />
|
||||||
|
</button>
|
||||||
|
</.form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- entries --%>
|
||||||
|
<div id="activity-entries" phx-update="stream">
|
||||||
|
<div id="activity-empty" class="hidden only:block text-sm text-base-content/60 py-8 text-center">
|
||||||
|
No activity to show.
|
||||||
|
</div>
|
||||||
|
<div :for={{dom_id, entry} <- @streams.entries} id={dom_id} class="admin-activity-row">
|
||||||
|
<div class={["admin-activity-icon", activity_icon_class(entry.level)]}>
|
||||||
|
<.icon name={activity_icon(entry.level)} class="size-4" />
|
||||||
|
</div>
|
||||||
|
<div class="admin-activity-body">
|
||||||
|
<span class="admin-activity-type">{format_event_type(entry.event_type)}</span>
|
||||||
|
<span>{entry.message}</span>
|
||||||
|
<.link
|
||||||
|
:if={entry.order_id}
|
||||||
|
navigate={~p"/admin/orders/#{entry.order_id}"}
|
||||||
|
class="text-primary hover:underline text-xs ml-1"
|
||||||
|
>
|
||||||
|
View order →
|
||||||
|
</.link>
|
||||||
|
</div>
|
||||||
|
<div class="admin-activity-meta">
|
||||||
|
<time class="admin-activity-time" datetime={DateTime.to_iso8601(entry.occurred_at)}>
|
||||||
|
{relative_time(entry.occurred_at)}
|
||||||
|
</time>
|
||||||
|
<.entry_action entry={entry} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<.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"""
|
||||||
|
<button
|
||||||
|
phx-click="category"
|
||||||
|
phx-value-category={@category || ""}
|
||||||
|
class={[
|
||||||
|
"admin-btn admin-btn-xs",
|
||||||
|
@active && "admin-btn-primary",
|
||||||
|
!@active && "admin-btn-ghost"
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{@label}
|
||||||
|
</button>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp entry_action(
|
||||||
|
%{entry: %{event_type: "order.submission_failed", order_id: order_id}} = assigns
|
||||||
|
)
|
||||||
|
when not is_nil(order_id) do
|
||||||
|
~H"""
|
||||||
|
<button
|
||||||
|
phx-click="retry_submission"
|
||||||
|
phx-value-order-id={@entry.order_id}
|
||||||
|
phx-value-entry-id={@entry.id}
|
||||||
|
class="admin-btn admin-btn-xs admin-btn-primary"
|
||||||
|
>
|
||||||
|
Retry submission
|
||||||
|
</button>
|
||||||
|
"""
|
||||||
|
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"""
|
||||||
|
<button
|
||||||
|
:if={@connection_id}
|
||||||
|
phx-click="retry_sync"
|
||||||
|
phx-value-connection-id={@connection_id}
|
||||||
|
phx-value-entry-id={@entry.id}
|
||||||
|
class="admin-btn admin-btn-xs admin-btn-primary"
|
||||||
|
>
|
||||||
|
Retry sync
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:if={!@connection_id}
|
||||||
|
phx-click="resolve"
|
||||||
|
phx-value-id={@entry.id}
|
||||||
|
class="admin-btn admin-btn-xs admin-btn-ghost"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
"""
|
||||||
|
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"""
|
||||||
|
<button
|
||||||
|
:if={@order_related && @entry.order_id}
|
||||||
|
phx-click="retry_submission"
|
||||||
|
phx-value-order-id={@entry.order_id}
|
||||||
|
phx-value-entry-id={@entry.id}
|
||||||
|
class="admin-btn admin-btn-xs admin-btn-primary"
|
||||||
|
>
|
||||||
|
Retry submission
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:if={!@order_related || !@entry.order_id}
|
||||||
|
phx-click="resolve"
|
||||||
|
phx-value-id={@entry.id}
|
||||||
|
class="admin-btn admin-btn-xs admin-btn-ghost"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp entry_action(%{entry: %{level: level, resolved_at: nil}} = assigns)
|
||||||
|
when level in ["warning", "error"] do
|
||||||
|
~H"""
|
||||||
|
<button
|
||||||
|
phx-click="resolve"
|
||||||
|
phx-value-id={@entry.id}
|
||||||
|
class="admin-btn admin-btn-xs admin-btn-ghost"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
"""
|
||||||
|
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
|
||||||
@ -1,7 +1,7 @@
|
|||||||
defmodule BerrypodWeb.Admin.OrderShow do
|
defmodule BerrypodWeb.Admin.OrderShow do
|
||||||
use BerrypodWeb, :live_view
|
use BerrypodWeb, :live_view
|
||||||
|
|
||||||
alias Berrypod.Orders
|
alias Berrypod.{ActivityLog, Orders}
|
||||||
alias Berrypod.Cart
|
alias Berrypod.Cart
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
@ -16,15 +16,25 @@ defmodule BerrypodWeb.Admin.OrderShow do
|
|||||||
{:ok, socket}
|
{:ok, socket}
|
||||||
|
|
||||||
order ->
|
order ->
|
||||||
|
if connected?(socket), do: ActivityLog.subscribe(order.id)
|
||||||
|
|
||||||
|
timeline = ActivityLog.list_for_order(order.id)
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, order.order_number)
|
|> assign(:page_title, order.order_number)
|
||||||
|> assign(:order, order)
|
|> assign(:order, order)
|
||||||
|
|> assign(:timeline, timeline)
|
||||||
|
|
||||||
{:ok, socket}
|
{:ok, socket}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info({:new_activity, entry}, socket) do
|
||||||
|
{:noreply, assign(socket, :timeline, socket.assigns.timeline ++ [entry])}
|
||||||
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
@ -95,43 +105,14 @@ defmodule BerrypodWeb.Admin.OrderShow do
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%!-- fulfilment --%>
|
<%!-- timeline --%>
|
||||||
<div class="admin-card mt-6">
|
<div class="admin-card mt-6">
|
||||||
<div class="admin-card-body">
|
<div class="admin-card-body">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h3 class="admin-card-title">Fulfilment</h3>
|
<h3 class="admin-card-title">Timeline</h3>
|
||||||
<.fulfilment_badge status={@order.fulfilment_status} />
|
<.fulfilment_badge status={@order.fulfilment_status} />
|
||||||
</div>
|
</div>
|
||||||
<.list>
|
<div class="flex gap-2 mt-2 mb-4">
|
||||||
<:item :if={@order.provider_order_id} title="Provider order ID">
|
|
||||||
<code class="text-xs">{@order.provider_order_id}</code>
|
|
||||||
</:item>
|
|
||||||
<:item :if={@order.provider_status} title="Provider status">
|
|
||||||
{@order.provider_status}
|
|
||||||
</:item>
|
|
||||||
<:item :if={@order.submitted_at} title="Submitted">
|
|
||||||
{format_date(@order.submitted_at)}
|
|
||||||
</:item>
|
|
||||||
<:item :if={@order.tracking_number} title="Tracking">
|
|
||||||
<%= if @order.tracking_url do %>
|
|
||||||
<a href={@order.tracking_url} target="_blank" class="admin-link">
|
|
||||||
{@order.tracking_number}
|
|
||||||
</a>
|
|
||||||
<% else %>
|
|
||||||
{@order.tracking_number}
|
|
||||||
<% end %>
|
|
||||||
</:item>
|
|
||||||
<:item :if={@order.shipped_at} title="Shipped">
|
|
||||||
{format_date(@order.shipped_at)}
|
|
||||||
</:item>
|
|
||||||
<:item :if={@order.delivered_at} title="Delivered">
|
|
||||||
{format_date(@order.delivered_at)}
|
|
||||||
</:item>
|
|
||||||
<:item :if={@order.fulfilment_error} title="Error">
|
|
||||||
<span class="text-error text-sm">{@order.fulfilment_error}</span>
|
|
||||||
</:item>
|
|
||||||
</.list>
|
|
||||||
<div class="flex gap-2 mt-4">
|
|
||||||
<button
|
<button
|
||||||
:if={can_submit?(@order)}
|
:if={can_submit?(@order)}
|
||||||
phx-click="submit_to_provider"
|
phx-click="submit_to_provider"
|
||||||
@ -150,6 +131,23 @@ defmodule BerrypodWeb.Admin.OrderShow do
|
|||||||
<.icon name="hero-arrow-path-mini" class="size-4" /> Refresh status
|
<.icon name="hero-arrow-path-mini" class="size-4" /> Refresh status
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
:if={@order.tracking_number not in [nil, ""]}
|
||||||
|
class="flex items-center gap-2 mb-4 text-sm"
|
||||||
|
>
|
||||||
|
<.icon name="hero-truck-mini" class="size-4 text-base-content/60" />
|
||||||
|
<span class="font-medium">{@order.tracking_number}</span>
|
||||||
|
<a
|
||||||
|
:if={@order.tracking_url not in [nil, ""]}
|
||||||
|
href={@order.tracking_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Track shipment →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<.order_timeline entries={@timeline} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -234,6 +232,43 @@ defmodule BerrypodWeb.Admin.OrderShow do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# ── Components ──
|
||||||
|
|
||||||
|
defp order_timeline(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div :if={@entries == []} class="text-sm text-base-content/60 py-4">
|
||||||
|
No activity recorded yet.
|
||||||
|
</div>
|
||||||
|
<ol :if={@entries != []} class="admin-timeline" id="order-timeline">
|
||||||
|
<li :for={entry <- @entries} class="admin-timeline-item">
|
||||||
|
<div class={["admin-timeline-dot", timeline_dot_class(entry.level)]}></div>
|
||||||
|
<div class="admin-timeline-content">
|
||||||
|
<p class="admin-timeline-message">{entry.message}</p>
|
||||||
|
<time class="admin-timeline-time" datetime={DateTime.to_iso8601(entry.occurred_at)}>
|
||||||
|
{format_timeline_time(entry.occurred_at)}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
"""
|
||||||
|
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
|
defp can_submit?(order) do
|
||||||
order.payment_status == "paid" and order.fulfilment_status in ["unfulfilled", "failed"]
|
order.payment_status == "paid" and order.fulfilment_status in ["unfulfilled", "failed"]
|
||||||
end
|
end
|
||||||
|
|||||||
@ -148,6 +148,7 @@ defmodule BerrypodWeb.Router do
|
|||||||
live "/analytics", Admin.Analytics, :index
|
live "/analytics", Admin.Analytics, :index
|
||||||
live "/orders", Admin.Orders, :index
|
live "/orders", Admin.Orders, :index
|
||||||
live "/orders/:id", Admin.OrderShow, :show
|
live "/orders/:id", Admin.OrderShow, :show
|
||||||
|
live "/activity", Admin.Activity, :index
|
||||||
live "/products", Admin.Products, :index
|
live "/products", Admin.Products, :index
|
||||||
live "/products/:id", Admin.ProductShow, :show
|
live "/products/:id", Admin.ProductShow, :show
|
||||||
live "/providers", Admin.Providers.Index, :index
|
live "/providers", Admin.Providers.Index, :index
|
||||||
|
|||||||
102
priv/repo/migrations/20260301133937_create_activity_log.exs
Normal file
102
priv/repo/migrations/20260301133937_create_activity_log.exs
Normal file
@ -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
|
||||||
49
test/berrypod/activity_log/oban_telemetry_handler_test.exs
Normal file
49
test/berrypod/activity_log/oban_telemetry_handler_test.exs
Normal file
@ -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
|
||||||
299
test/berrypod/activity_log_test.exs
Normal file
299
test/berrypod/activity_log_test.exs
Normal file
@ -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:<id> 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
|
||||||
169
test/berrypod_web/live/admin/activity_test.exs
Normal file
169
test/berrypod_web/live/admin/activity_test.exs
Normal file
@ -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
|
||||||
@ -116,12 +116,12 @@ defmodule BerrypodWeb.Admin.OrdersTest do
|
|||||||
live(conn, ~p"/admin/orders/#{fake_id}")
|
live(conn, ~p"/admin/orders/#{fake_id}")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "shows fulfilment card", %{conn: conn} do
|
test "shows timeline card", %{conn: conn} do
|
||||||
order = order_fixture(payment_status: "paid")
|
order = order_fixture(payment_status: "paid")
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, ~p"/admin/orders/#{order}")
|
{:ok, _view, html} = live(conn, ~p"/admin/orders/#{order}")
|
||||||
|
|
||||||
assert html =~ "Fulfilment"
|
assert html =~ "Timeline"
|
||||||
assert html =~ "unfulfilled"
|
assert html =~ "unfulfilled"
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -172,6 +172,49 @@ defmodule BerrypodWeb.Admin.OrdersTest do
|
|||||||
end
|
end
|
||||||
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
|
describe "order list fulfilment column" do
|
||||||
setup %{conn: conn, user: user} do
|
setup %{conn: conn, user: user} do
|
||||||
conn = log_in_user(conn, user)
|
conn = log_in_user(conn, user)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user