Phase 2: analytics rollups, geo breakdown, event feed (backend)
CI / policy (push) Successful in 4s
CI / backend (push) Failing after 1m13s
CI / mobile (push) Successful in 19s
CI / profile (push) Successful in 48s

- Idempotent hourly rollup (run_rollup, INSERT ... ON CONFLICT DO UPDATE into
  analytics_rollups_hourly) + Tokio background task (startup then hourly)
- GET /v1/cards/{id}/analytics/geo — country-grouped visit counts
- GeoResolver trait: real MaxMind under the `geoip` feature, no-op default;
  raw IP resolved then discarded (only hash + coarse country/city persisted)
- analytics_events now store country/city; rollups read query for the dashboard

2 new tests (geo grouping, rollup aggregation + idempotency); 75 backend tests.
fmt + clippy clean; geoip feature compiles.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-04 11:34:52 -05:00
co-authored by Claude Opus 4.8
parent e66813ef58
commit 4770efd861
14 changed files with 419 additions and 11 deletions
@@ -103,6 +103,84 @@ async fn ingest_rejects_server_only_event_type() {
assert_eq!(body["code"], "validation");
}
#[tokio::test]
async fn geo_breakdown_groups_by_country() {
let app = require_app!();
let token = app.register_and_token().await;
let (id, _) = create_and_publish(&app, &token).await;
// Two client events with a forwarded IP → FakeGeo resolves both to US.
for _ in 0..2 {
app.request_with_headers(
"POST",
"/v1/analytics/event",
None,
Some(json!({ "card_id": id, "event_type": "contact_save" })),
&[("x-forwarded-for", "203.0.113.7")],
)
.await;
}
let (status, body) = app
.request(
"GET",
&format!("/v1/cards/{id}/analytics/geo"),
Some(&token),
None,
)
.await;
assert_eq!(status, StatusCode::OK);
let rows = body.as_array().unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0]["country"], "US");
assert_eq!(rows[0]["visits"], 2);
}
#[tokio::test]
async fn hourly_rollup_aggregates_and_is_idempotent() {
use cardclaws_db::queries::analytics;
use uuid::Uuid;
let app = require_app!();
let token = app.register_and_token().await;
let (id, handle) = create_and_publish(&app, &token).await;
let card_id = Uuid::parse_str(&id).unwrap();
// Generate two profile visits and one qr_scan (via a share resolve).
app.request("GET", &format!("/v1/cards/handle/{handle}"), None, None)
.await;
app.request("GET", &format!("/v1/cards/handle/{handle}"), None, None)
.await;
let (_, share) = app
.request(
"POST",
&format!("/v1/cards/{id}/share"),
Some(&token),
Some(json!({"modality": "qr"})),
)
.await;
app.get_redirect(&format!("/v1/s/{}", share["token"].as_str().unwrap()))
.await;
let affected = analytics::run_rollup(&app.db).await.unwrap();
assert!(affected >= 1);
let rollups = analytics::rollups(&app.db, card_id).await.unwrap();
let totals = rollups
.iter()
.fold((0, 0), |(v, q), r| (v + r.visits, q + r.qr_scans));
assert_eq!(totals.0, 2, "two profile visits rolled up");
assert_eq!(totals.1, 1, "one qr scan rolled up");
// Re-running must not double-count (ON CONFLICT DO UPDATE).
analytics::run_rollup(&app.db).await.unwrap();
let rollups2 = analytics::rollups(&app.db, card_id).await.unwrap();
let totals2 = rollups2
.iter()
.fold((0, 0), |(v, q), r| (v + r.visits, q + r.qr_scans));
assert_eq!(totals, totals2, "rollup is idempotent");
}
#[tokio::test]
async fn analytics_summary_requires_ownership() {
let app = require_app!();