Repository navigation
feat(admin): MAU dashboard + metrics audit + dashboard improvements - #1636
Conversation
📝 WalkthroughWalkthroughThis PR extends the admin dashboard with four major analytics features: a new Growth page tracking MAU/DAU trends and tier distribution, user dormancy metrics based on session activity, a contact-submission resolution workflow, and margin breakdown analytics by tier and billing period. The changes refactor monitoring queries from userActivities to activityLogs and sessions, add database support for tracking contact submission resolution, and introduce navigation badges for health alerts. ChangesAdmin Dashboard Extensions
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a30d712ac0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
a30d712 to
f77484f
Compare
- Fix broken userActivities signal: switch active-user count to sessions.lastUsedAt and all trend/heatmap queries to activityLogs - Add /growth page: MAU/WAU/DAU stickiness, 12-month trend, tier pie, daily activity bar chart, MoM growth card - Add getGrowthMetrics() query (MAU/WAU/DAU from sessions, trends from activityLogs, signups from users.createdAt) - Users page: last-active column from sessions + dormant filter toggle - Unit Economics: add margin-by-tier table; wire getMarginByTier() - Support: link submissions to registered user accounts by email match, add open/closed status toggle (resolvedAt column + PATCH endpoint) - AI Billing: add margin trend line chart (real cost vs charged per day) - Nav: fetch /api/admin/alerts on mount; show red dot on Monitoring tab when error rate > 5%, on AI Billing/Unit Economics when negative-margin accounts exist or live holds > 50 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ATCH - alerts/route.ts: use getApiMetrics (not getSystemHealth) for error rate — errorRate is computed in getApiMetrics, getSystemHealth only returns log levels - contact/[id]/route.ts: use explicit RouteContext generic for withAdminAuth instead of unsafe type cast, matching convention in gift-subscription route - journal: correct migration timestamp to present day Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
P1 — MAU/WAU/DAU: drop isNull(revokedAt) filter so users who were active in the window but later logged out still count; revokedAt is current session state, not a historical activity gate. P2 — MoM growth: switch from partial-calendar-month vs full-last-month to rolling 30d vs prior 30d, eliminating the systematic negative-growth bias before month-end. P2 — Map date keys: DATE_TRUNC returns Date objects from node-postgres despite sql<string> annotation; normalise via toISOString() before using as Map keys so signup-trend lookups don't silently return zero. Update growth page stat labels to reflect rolling-window semantics. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
d6df272 to
46b55b1
Compare
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…schema import Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…eration db:generate in CI reads the latest snapshot and diffs against the current schema. Without 0161_snapshot.json, it would regenerate the resolvedAt column migration causing 'column already exists' on db:migrate. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 7
🧹 Nitpick comments (1)
apps/admin/src/app/api/admin/contact/[id]/route.ts (1)
12-12: 💤 Low valueConsider validating the request body.
The body is cast without validation. If
resolvedis missing or has an unexpected type, the behavior may be surprising (e.g.,undefinedis falsy → setsresolvedAttonull). For an admin-only endpoint this is low risk, but explicit validation would be cleaner.♻️ Optional validation
- const body = await request.json() as { resolved: boolean }; + const body = await request.json() as { resolved?: unknown }; + if (typeof body.resolved !== 'boolean') { + return Response.json({ error: 'Invalid request: resolved must be a boolean' }, { status: 400 }); + }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/admin/src/app/api/admin/contact/`[id]/route.ts at line 12, The handler currently casts request.json() to { resolved: boolean } without checking; update the route.ts request handler to validate that the parsed body has a boolean resolved property (e.g., check typeof body?.resolved === 'boolean'), and if not return a 400/throw a BadRequest response; then use the validated boolean to decide resolvedAt (set to new Date() when true, null when false) and proceed to updateContact (keep function name updateContact) so behavior is explicit and safe.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/admin/src/app/`(admin)/ai-billing/page.tsx:
- Around line 301-306: The data passed to LineChart is in newest-first order
(data.marginByPeriod from getMarginByPeriod()), so reverse or sort that array
into chronological order before mapping for the chart; locate the LineChart
usage in page.tsx and change the mapped source from data.marginByPeriod to a
chronologically ordered array (e.g., data.marginByPeriod.slice().reverse() or
sort by r.period) so the series (realCost/charged/margin) are rendered
left-to-right in time.
In `@apps/admin/src/app/`(admin)/growth/page.tsx:
- Around line 98-108: The fetchData function currently calls fetchWithAuth and
unconditionally does setData(json.data) which can overwrite cached data when the
Response is non-OK; update fetchData to check res.ok after awaiting
fetchWithAuth and only call setData(json.data) when res.ok is true, otherwise
preserve the existing data (and optionally read/log the error payload from
res.json or handle it via your error UI); ensure setIsLoading still runs in
finally and do not swallow the non-OK response path so stale dashboard data
remains visible.
In `@apps/admin/src/app/AdminLayoutClient.tsx`:
- Around line 40-44: The code calls fetchWithAuth('/api/admin/alerts') and
passes the untyped r.json() result straight into setAlerts (inside useEffect),
bypassing the expected AlertState shape; update the fetch chain to
narrow/validate the parsed JSON before calling setAlerts: after r.json() run a
runtime validator or mapping to pick/convert only the expected fields into
AlertState (e.g., validate array, required keys, types, and map to the
AlertState shape), and only call setAlerts(validatedValue) when the shape checks
pass (otherwise ignore/log); reference useEffect, fetchWithAuth, setAlerts and
the AlertState shape when adding the validation step.
In `@apps/admin/src/app/api/admin/ai-billing/route.ts`:
- Around line 16-17: The CSV branches in the AI billing and unit economics
routes are still emitting the old schema; update the CSV serialization logic in
the format=csv branches of the handlers that use marginByPeriod and byTier so
they emit the same fields and derived metrics as the JSON response (e.g.,
include the new metrics produced by getMarginByPeriod and the new tier
aggregates). Locate the format=csv code paths in the route handlers that
reference marginByPeriod and byTier, align column headers and row generation
with the JSON payload shape, and ensure any derived calculations (totals,
percentages) match the JSON-side logic; consider extracting a shared serializer
function to avoid future drift between JSON and CSV outputs.
In `@apps/admin/src/app/api/admin/users/route.ts`:
- Around line 85-92: The aggregation currently uses MAX(${sessions.lastUsedAt})
which yields null when lastUsedAt is unset; update the aggregation in the
db.select for lastActiveAt to use MAX(COALESCE(${sessions.lastUsedAt},
${sessions.createdAt})) so createdAt is used as a fallback. Locate the select
that builds lastActiveAt (db.select... { userId: sessions.userId, lastActiveAt:
sql<Date>`MAX(${sessions.lastUsedAt})` }) and replace the expression with
MAX(COALESCE(...)) referencing sessions.lastUsedAt and sessions.createdAt to
ensure users with no lastUsedAt still show a recent activity timestamp.
In `@apps/admin/src/components/admin/ContactSubmissionsTable.tsx`:
- Around line 130-147: The toggle currently silently fails when the PATCH
returns a non-2xx: update handleToggleResolved to capture and surface errors by
adding a resolve error state (e.g., resolveError via useState) and setting it
when response.ok is false or fetch throws; keep existing setResolvingId and
setLocalResolved logic but on failure set resolveError and leave localResolved
unchanged, and trigger a user-visible notification (toast or inline alert) in
the component UI that reads resolveError so users see the failure; ensure the
finally block still clears setResolvingId to re-enable the button.
In `@apps/admin/src/lib/monitoring-queries.ts`:
- Around line 1207-1318: mauTrend and dauTrend must use fixed calendar buckets
instead of only mapping grouped rows: generate an explicit 12-month array of
month-start keys and a 30-day array of day-start keys (using the same toKey
normaliser) and then populate each bucket from mauTrendRaw/signupsByMonthRaw and
dauTrendRaw/signupsByDayRaw respectively so months/days with zero activity but
non-zero signups are included and you only produce exactly 12 months and 30
days; update the logic that computes the month-window (currently
twelveMonthsAgo) so the bucket start is the correct 12-month range (create the
first month by new Date(now.getFullYear(), now.getMonth(), 1) and iterate
backwards/forwards to produce 12 month boundaries), and fill mauTrend and
dauTrend from those generated buckets using mauTrendRaw/dauTrendRaw and signups
maps (refer to mauTrendRaw, signupsByMonthRaw, mauTrend, dauTrendRaw,
signupsByDayRaw, toKey).
---
Nitpick comments:
In `@apps/admin/src/app/api/admin/contact/`[id]/route.ts:
- Line 12: The handler currently casts request.json() to { resolved: boolean }
without checking; update the route.ts request handler to validate that the
parsed body has a boolean resolved property (e.g., check typeof body?.resolved
=== 'boolean'), and if not return a 400/throw a BadRequest response; then use
the validated boolean to decide resolvedAt (set to new Date() when true, null
when false) and proceed to updateContact (keep function name updateContact) so
behavior is explicit and safe.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 2c6b55bb-6f27-4407-93c6-478042db7767
📒 Files selected for processing (22)
apps/admin/src/app/(admin)/ai-billing/page.tsxapps/admin/src/app/(admin)/dashboard/page.tsxapps/admin/src/app/(admin)/growth/page.tsxapps/admin/src/app/(admin)/support/page.tsxapps/admin/src/app/(admin)/unit-economics/page.tsxapps/admin/src/app/(admin)/users/page.tsxapps/admin/src/app/AdminLayoutClient.tsxapps/admin/src/app/api/admin/ai-billing/route.tsapps/admin/src/app/api/admin/alerts/route.tsapps/admin/src/app/api/admin/contact/[id]/route.tsapps/admin/src/app/api/admin/contact/route.tsapps/admin/src/app/api/admin/unit-economics/route.tsapps/admin/src/app/api/admin/users/route.tsapps/admin/src/app/api/monitoring/[metric]/route.tsapps/admin/src/components/admin/ContactSubmissionsTable.tsxapps/admin/src/components/admin/UsersTable.tsxapps/admin/src/lib/monitoring-queries.tsapps/admin/src/lib/monitoring-types.tspackages/db/drizzle/0161_contact_resolved_at.sqlpackages/db/drizzle/meta/0161_snapshot.jsonpackages/db/drizzle/meta/_journal.jsonpackages/db/src/schema/contact.ts
- Sort marginByPeriod ascending before LineChart render (was newest-first) - Check res.ok in growth page fetch before setting stale data to undefined - Type-guard alerts JSON in AdminLayoutClient before setAlerts (was untyped any) - Add marginByPeriod rows to ai-billing CSV export (was JSON-only) - Add byTier rows to unit-economics CSV export (was JSON-only) - COALESCE(lastUsedAt, createdAt) in users last-active query; remove revokedAt filter so logged-out users still show their last active date - Add error feedback in ContactSubmissionsTable PATCH (was silent failure) - Fix 13-month MAU trend (twelveMonthsAgo now 11 months back, not 12) - Generate fixed 12-month and 30-day calendar buckets for growth charts so months/days with zero activity but non-zero signups still appear Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Addressed all CodeRabbit review comments in commit d03018b:
|
Summary
userActivitieswas never written in normal app use — all active-user queries now usesessions.lastUsedAt(current snapshot) andactivityLogs(trends/heatmap/feature-usage)/growth): MAU/WAU/DAU stickiness cards, 12-month MAU trend chart (recharts AreaChart), tier breakdown pie, daily activity bar, rolling 30d signup growth statresolvedAtcolumn, PATCH endpoint, and all/open/closed filter/api/admin/alertsendpoint (60s cache); red dot on Monitoring (error rate > 5%), AI Billing + Unit Economics (negative-margin accounts or live holds > 50)Post-review fixes (Codex P1/P2)
isNull(revokedAt)filter from window queries — users who were active during the period but later logged out still count. The filter is correct only for "currently online" metrics.DATE_TRUNCreturnsDateobjects from node-postgres despitesql<string>annotation. AddedtoKey()normalisation to ISO strings before constructing/reading Maps, fixing the signup-trend join silently returning zero.contact/[id]/route.tsnow uses thewithAdminAuth<RouteContext>generic overload (matchinggift-subscriptionpattern) instead of an unsafe type cast.getApiMetrics(notgetSystemHealth) for error rate —errorRateis only computed ingetApiMetrics.userActivitiesimport and stalestartOfThisMonth/startOfLastMonthvars (leftover after rolling-window refactor).Post-review fixes (CodeRabbit)
marginByPeriodnow sorted ascending beforeLineChart— was rendering newest-first making the trend slope look backwards.if (!res.ok) throwbeforeres.json()— a non-2xx response was clobbering the stale cache withundefined.isAlertState()runtime type-guard beforesetAlerts.marginByPeriodrows to ai-billing CSV andbyTierrows to unit-economics CSV — both were JSON-only.MAX(COALESCE(lastUsedAt, createdAt))across ALL sessions (removedisNull(revokedAt)filter) — logged-out users now show their last active date.twelveMonthsAgowasgetFullYear()-1, getMonth()(13 months); changed togetMonth()-11(exactly 12). BothmauTrendanddauTrendnow use fixed 12-month/30-day UTC bucket arrays so months/days with zero activity but non-zero signups are included.DB migration
0161_contact_resolved_at.sql— adds nullableresolvedAttimestamp tocontact_submissions.Test plan
/growthpage loads, charts render, stat cards show MAU/WAU/DAUbun run db:migratebefore testing support resolve feature🤖 Generated with Claude Code