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
+9 -7
View File
@@ -91,13 +91,15 @@ bash scripts/policy-grep.sh # no stub/placeholder markers (PRD §15.4)
event (qr→qr_scan, nfc→nfc_tap, …) and 302-redirects to the profile; event (qr→qr_scan, nfc→nfc_tap, …) and 302-redirects to the profile;
`GET /v1/cards/{id}/share-links` lists them. Events carry the `share_token` `GET /v1/cards/{id}/share-links` lists them. Events carry the `share_token`
correlation, and `GET /v1/cards/{id}/analytics/feed` returns the chronological correlation, and `GET /v1/cards/{id}/analytics/feed` returns the chronological
feed. `cardclaws-wallet` crate feed.
(pass.json builder, SHA-1 manifest, strip render, zip packager, PKCS#7 - **Analytics rollups + geo (backend)** — done. Idempotent hourly rollup
OpenSSL signer behind the `apple-signing` feature) wired at (`ON CONFLICT DO UPDATE`) into `analytics_rollups_hourly`, run by a Tokio
`POST /v1/cards/{id}/wallet/apple`. Analytics ingest + summary background task; `GET /v1/cards/{id}/analytics/geo` (country breakdown). Geo
(`/v1/analytics/event`, `/v1/cards/{id}/analytics`) with daily-salted IP lookup is behind a `GeoResolver` trait — real MaxMind under the `geoip`
hashing; `profile_visit` recorded on public handle lookup. feature, no-op otherwise; the raw IP is resolved then discarded (only the
Remaining for B4: the Astro web profile (client surface) + mobile viewer/flip. hash + coarse country/city are stored).
- **Remaining:** mobile advanced layers + share/QR/NFC UI + analytics dashboard
screen (P2-3), then Android + Google Wallet.
Backend tests: **67 passing** (unit + integration). Run with a live Postgres Backend tests: **67 passing** (unit + integration). Run with a live Postgres
(`TEST_DATABASE_URL`) to exercise the integration suite. (`TEST_DATABASE_URL`) to exercise the integration suite.
+22
View File
@@ -265,6 +265,7 @@ dependencies = [
"cardclaws-wallet", "cardclaws-wallet",
"chrono", "chrono",
"http-body-util", "http-body-util",
"maxminddb",
"rand 0.8.6", "rand 0.8.6",
"redis", "redis",
"reqwest", "reqwest",
@@ -1193,6 +1194,15 @@ version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "ipnetwork"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "itertools" name = "itertools"
version = "0.13.0" version = "0.13.0"
@@ -1326,6 +1336,18 @@ version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "maxminddb"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6087e5d8ea14861bb7c7f573afbc7be3798d3ef0fae87ec4fd9a4de9a127c3c"
dependencies = [
"ipnetwork",
"log",
"memchr",
"serde",
]
[[package]] [[package]]
name = "md-5" name = "md-5"
version = "0.10.6" version = "0.10.6"
@@ -14,6 +14,7 @@ workspace = true
# structurally-correct but unsigned passes — logged loudly at startup. # structurally-correct but unsigned passes — logged loudly at startup.
default = [] default = []
apple-signing = ["cardclaws-wallet/apple-signing"] apple-signing = ["cardclaws-wallet/apple-signing"]
geoip = ["dep:maxminddb"]
[[bin]] [[bin]]
name = "cardclaws-api" name = "cardclaws-api"
@@ -43,6 +44,7 @@ rand = { workspace = true }
base64 = { workspace = true } base64 = { workspace = true }
validator = { workspace = true } validator = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
maxminddb = { version = "0.24", optional = true }
tracing = { workspace = true } tracing = { workspace = true }
tracing-subscriber = { workspace = true } tracing-subscriber = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
@@ -51,6 +53,7 @@ thiserror = { workspace = true }
cardclaws-auth = { workspace = true } cardclaws-auth = { workspace = true }
cardclaws-wallet = { workspace = true } cardclaws-wallet = { workspace = true }
cardclaws-config = { workspace = true } cardclaws-config = { workspace = true }
cardclaws-db = { workspace = true }
tower = { workspace = true, features = ["util"] } tower = { workspace = true, features = ["util"] }
http-body-util = "0.1" http-body-util = "0.1"
tokio = { workspace = true } tokio = { workspace = true }
@@ -0,0 +1,88 @@
//! IP → country/city resolution for analytics geo distribution (PRD §6.7.1,
//! §18). Behind a trait so the real MaxMind lookup (which needs a GeoLite2 mmdb
//! file) is optional and tests can inject a deterministic fake.
//!
//! The real resolver is gated behind the `geoip` feature; without it the
//! `NullGeoResolver` is used and country/city stay unresolved (analytics still
//! work, just without geo).
use std::net::IpAddr;
pub struct GeoLocation {
pub country: Option<String>,
pub city: Option<String>,
}
pub trait GeoResolver: Send + Sync {
/// Resolve a raw IP string to a location. Never errors — unknown IPs simply
/// return empty fields.
fn resolve(&self, ip: &str) -> GeoLocation;
}
/// Default resolver: resolves nothing. Used when `geoip` is disabled.
pub struct NullGeoResolver;
impl GeoResolver for NullGeoResolver {
fn resolve(&self, _ip: &str) -> GeoLocation {
GeoLocation {
country: None,
city: None,
}
}
}
#[cfg(feature = "geoip")]
pub use maxmind::MaxMindGeoResolver;
#[cfg(feature = "geoip")]
mod maxmind {
use std::net::IpAddr;
use std::sync::Arc;
use maxminddb::{geoip2, Reader};
use super::{GeoLocation, GeoResolver};
/// Production resolver backed by a MaxMind GeoLite2/GeoIP2 City database.
pub struct MaxMindGeoResolver {
reader: Arc<Reader<Vec<u8>>>,
}
impl MaxMindGeoResolver {
pub fn open(mmdb_path: &str) -> Result<Self, String> {
let reader = Reader::open_readfile(mmdb_path).map_err(|e| e.to_string())?;
Ok(Self {
reader: Arc::new(reader),
})
}
}
impl GeoResolver for MaxMindGeoResolver {
fn resolve(&self, ip: &str) -> GeoLocation {
let Ok(addr) = ip.parse::<IpAddr>() else {
return GeoLocation {
country: None,
city: None,
};
};
match self.reader.lookup::<geoip2::City>(addr) {
Ok(city) => GeoLocation {
country: city.country.and_then(|c| c.iso_code).map(|s| s.to_string()),
city: city
.city
.and_then(|c| c.names)
.and_then(|n| n.get("en").map(|s| s.to_string())),
},
Err(_) => GeoLocation {
country: None,
city: None,
},
}
}
}
}
/// Validate that a string parses as an IP (used to avoid storing junk).
pub fn is_ip(s: &str) -> bool {
s.parse::<IpAddr>().is_ok()
}
@@ -3,7 +3,7 @@
use axum::extract::{Path, State}; use axum::extract::{Path, State};
use axum::http::HeaderMap; use axum::http::HeaderMap;
use axum::Json; use axum::Json;
use cardclaws_db::models::analytics::{AnalyticsSummary, FeedEvent}; use cardclaws_db::models::analytics::{AnalyticsSummary, FeedEvent, GeoCount};
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
use uuid::Uuid; use uuid::Uuid;
@@ -63,6 +63,17 @@ pub async fn feed(
)) ))
} }
/// Owner-only geo distribution (country → visits).
pub async fn geo(
State(state): State<AppState>,
user: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<Json<Vec<GeoCount>>> {
Ok(Json(
analytics_service::geo_breakdown(&state, id, user.user_id).await?,
))
}
/// Best-effort client IP from the proxy headers Cloudflare/Hetzner set. The /// Best-effort client IP from the proxy headers Cloudflare/Hetzner set. The
/// first IP in `x-forwarded-for` is the original client. /// first IP in `x-forwarded-for` is the original client.
pub fn client_ip(headers: &HeaderMap) -> Option<String> { pub fn client_ip(headers: &HeaderMap) -> Option<String> {
@@ -8,6 +8,7 @@ pub mod assets;
pub mod cache; pub mod cache;
pub mod email; pub mod email;
pub mod error; pub mod error;
pub mod geo;
pub mod handlers; pub mod handlers;
pub mod middleware; pub mod middleware;
pub mod router; pub mod router;
@@ -6,6 +6,7 @@ use std::sync::Arc;
use cardclaws_api::assets::R2Store; use cardclaws_api::assets::R2Store;
use cardclaws_api::cache::RedisCache; use cardclaws_api::cache::RedisCache;
use cardclaws_api::email::ResendEmailSender; use cardclaws_api::email::ResendEmailSender;
use cardclaws_api::geo::{GeoResolver, NullGeoResolver};
use cardclaws_api::{build_router, AppState}; use cardclaws_api::{build_router, AppState};
use cardclaws_auth::apple::HttpJwkProvider; use cardclaws_auth::apple::HttpJwkProvider;
use cardclaws_auth::JwtKeys; use cardclaws_auth::JwtKeys;
@@ -46,6 +47,7 @@ async fn main() -> Result<(), BoxError> {
let cache = RedisCache::connect(&config.redis_url).await?; let cache = RedisCache::connect(&config.redis_url).await?;
let assets = R2Store::new(&config.r2)?; let assets = R2Store::new(&config.r2)?;
let pass_signer = build_pass_signer(&secrets).await?; let pass_signer = build_pass_signer(&secrets).await?;
let geo = build_geo_resolver(&secrets).await;
// Brand glyphs bundled into every pass. Solid-fill placeholders for now; // Brand glyphs bundled into every pass. Solid-fill placeholders for now;
// replaced by real CardClaws artwork when design assets land. // replaced by real CardClaws artwork when design assets land.
@@ -59,6 +61,7 @@ async fn main() -> Result<(), BoxError> {
cache: Arc::new(cache), cache: Arc::new(cache),
email: Arc::new(ResendEmailSender::new(resend_key, email_from)), email: Arc::new(ResendEmailSender::new(resend_key, email_from)),
assets: Arc::new(assets), assets: Arc::new(assets),
geo,
jwt: JwtKeys::new(&config.jwt_secret), jwt: JwtKeys::new(&config.jwt_secret),
apple: Arc::new(HttpJwkProvider::new()), apple: Arc::new(HttpJwkProvider::new()),
apple_audience, apple_audience,
@@ -69,6 +72,9 @@ async fn main() -> Result<(), BoxError> {
brand: Arc::new(brand), brand: Arc::new(brand),
}; };
// Hourly analytics rollup (PRD §18.2).
spawn_rollup_task(state.db.clone());
let app = build_router(state); let app = build_router(state);
let listener = tokio::net::TcpListener::bind(&config.bind_addr).await?; let listener = tokio::net::TcpListener::bind(&config.bind_addr).await?;
tracing::info!(addr = %config.bind_addr, "cardclaws-api listening"); tracing::info!(addr = %config.bind_addr, "cardclaws-api listening");
@@ -76,6 +82,43 @@ async fn main() -> Result<(), BoxError> {
Ok(()) Ok(())
} }
/// Spawn the background task that recomputes hourly analytics rollups. The first
/// tick fires immediately, then once per hour.
fn spawn_rollup_task(db: cardclaws_db::Db) {
tokio::spawn(async move {
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(3600));
loop {
ticker.tick().await;
match cardclaws_db::queries::analytics::run_rollup(&db).await {
Ok(n) => tracing::debug!(buckets = n, "analytics rollup complete"),
Err(e) => tracing::warn!(error = %e, "analytics rollup failed"),
}
}
});
}
/// Build the geo resolver. With `geoip` enabled and `MAXMIND_DB_PATH` set, uses
/// the MaxMind City database; otherwise geo is unresolved (analytics still work).
#[cfg(feature = "geoip")]
async fn build_geo_resolver(secrets: &dyn SecretSource) -> Arc<dyn GeoResolver> {
use cardclaws_api::geo::MaxMindGeoResolver;
if let Some(path) = secrets.get("MAXMIND_DB_PATH").await {
match MaxMindGeoResolver::open(&path) {
Ok(r) => {
tracing::info!("geoip enabled");
return Arc::new(r);
}
Err(e) => tracing::warn!(error = %e, "failed to open MaxMind db; geo disabled"),
}
}
Arc::new(NullGeoResolver)
}
#[cfg(not(feature = "geoip"))]
async fn build_geo_resolver(_secrets: &dyn SecretSource) -> Arc<dyn GeoResolver> {
Arc::new(NullGeoResolver)
}
/// Build the Apple pass signer. With `apple-signing` enabled, loads the Pass /// Build the Apple pass signer. With `apple-signing` enabled, loads the Pass
/// Type ID P12 + WWDR intermediate and signs for real; otherwise uses a fake /// Type ID P12 + WWDR intermediate and signs for real; otherwise uses a fake
/// signer (dev only) and warns loudly. /// signer (dev only) and warns loudly.
@@ -35,6 +35,7 @@ pub fn build_router(state: AppState) -> Router {
.route("/cards/handle/:handle", get(cards::get_card_by_handle)) .route("/cards/handle/:handle", get(cards::get_card_by_handle))
.route("/cards/:id/analytics", get(analytics::summary)) .route("/cards/:id/analytics", get(analytics::summary))
.route("/cards/:id/analytics/feed", get(analytics::feed)) .route("/cards/:id/analytics/feed", get(analytics::feed))
.route("/cards/:id/analytics/geo", get(analytics::geo))
.route("/cards/:id/share", post(share::create_share)) .route("/cards/:id/share", post(share::create_share))
.route("/cards/:id/share-links", get(share::list_shares)) .route("/cards/:id/share-links", get(share::list_shares))
.route("/s/:token", get(share::resolve_share)) .route("/s/:token", get(share::resolve_share))
@@ -68,6 +68,11 @@ pub async fn record(
) -> Result<(), AppError> { ) -> Result<(), AppError> {
debug_assert!(ALL_EVENT_TYPES.contains(&event_type)); debug_assert!(ALL_EVENT_TYPES.contains(&event_type));
let ip_hash = ip.map(|raw| hash_ip(&state.ip_hash_secret, raw)); let ip_hash = ip.map(|raw| hash_ip(&state.ip_hash_secret, raw));
// Resolve geo from the raw IP, then immediately drop the raw IP — only the
// hash and coarse country/city are persisted (PRD §18.3).
let geo = ip.map(|raw| state.geo.resolve(raw));
let country = geo.as_ref().and_then(|g| g.country.as_deref());
let city = geo.as_ref().and_then(|g| g.city.as_deref());
analytics::insert_event( analytics::insert_event(
&state.db, &state.db,
analytics::NewEvent { analytics::NewEvent {
@@ -75,6 +80,8 @@ pub async fn record(
event_type, event_type,
share_token, share_token,
ip_hash: ip_hash.as_deref(), ip_hash: ip_hash.as_deref(),
country,
city,
user_agent, user_agent,
}, },
) )
@@ -82,6 +89,16 @@ pub async fn record(
.map_db() .map_db()
} }
/// Owner-only geo distribution for a card.
pub async fn geo_breakdown(
state: &AppState,
card_id: Uuid,
user_id: Uuid,
) -> Result<Vec<cardclaws_db::models::analytics::GeoCount>, AppError> {
card_service::get_owned(state, card_id, user_id).await?;
analytics::geo_breakdown(&state.db, card_id).await.map_db()
}
/// Owner-only metrics summary for a card. /// Owner-only metrics summary for a card.
pub async fn summary( pub async fn summary(
state: &AppState, state: &AppState,
@@ -12,6 +12,7 @@ use cardclaws_wallet::apple::BrandAssets;
use crate::assets::ObjectStore; use crate::assets::ObjectStore;
use crate::cache::Cache; use crate::cache::Cache;
use crate::email::EmailSender; use crate::email::EmailSender;
use crate::geo::GeoResolver;
/// All shared dependencies. `Arc`-wrapped trait objects keep `AppState: Clone` /// All shared dependencies. `Arc`-wrapped trait objects keep `AppState: Clone`
/// cheap while allowing test doubles to be injected (cache, Apple keys). /// cheap while allowing test doubles to be injected (cache, Apple keys).
@@ -21,6 +22,7 @@ pub struct AppState {
pub cache: Arc<dyn Cache>, pub cache: Arc<dyn Cache>,
pub email: Arc<dyn EmailSender>, pub email: Arc<dyn EmailSender>,
pub assets: Arc<dyn ObjectStore>, pub assets: Arc<dyn ObjectStore>,
pub geo: Arc<dyn GeoResolver>,
pub jwt: JwtKeys, pub jwt: JwtKeys,
pub apple: Arc<dyn JwkProvider>, pub apple: Arc<dyn JwkProvider>,
/// Apple Services ID / bundle id the identity token must be addressed to. /// Apple Services ID / bundle id the identity token must be addressed to.
@@ -103,6 +103,84 @@ async fn ingest_rejects_server_only_event_type() {
assert_eq!(body["code"], "validation"); 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] #[tokio::test]
async fn analytics_summary_requires_ownership() { async fn analytics_summary_requires_ownership() {
let app = require_app!(); let app = require_app!();
@@ -21,6 +21,7 @@ use tower::ServiceExt;
use cardclaws_api::assets::InMemoryStore; use cardclaws_api::assets::InMemoryStore;
use cardclaws_api::cache::InMemoryCache; use cardclaws_api::cache::InMemoryCache;
use cardclaws_api::email::CapturingEmailSender; use cardclaws_api::email::CapturingEmailSender;
use cardclaws_api::geo::{GeoLocation, GeoResolver};
use cardclaws_api::{build_router, AppState}; use cardclaws_api::{build_router, AppState};
use cardclaws_auth::apple::{AppleAuthError, AppleJwks, JwkProvider}; use cardclaws_auth::apple::{AppleAuthError, AppleJwks, JwkProvider};
use cardclaws_auth::JwtKeys; use cardclaws_auth::JwtKeys;
@@ -40,10 +41,31 @@ impl JwkProvider for NoopApple {
} }
} }
/// Deterministic geo resolver for tests: any non-empty IP resolves to the US so
/// the geo endpoint can be exercised without a MaxMind database.
struct FakeGeo;
impl GeoResolver for FakeGeo {
fn resolve(&self, ip: &str) -> GeoLocation {
if ip.is_empty() {
GeoLocation {
country: None,
city: None,
}
} else {
GeoLocation {
country: Some("US".to_string()),
city: Some("San Francisco".to_string()),
}
}
}
}
pub struct TestApp { pub struct TestApp {
pub router: Router, pub router: Router,
pub email: Arc<CapturingEmailSender>, pub email: Arc<CapturingEmailSender>,
pub assets: Arc<InMemoryStore>, pub assets: Arc<InMemoryStore>,
pub db: cardclaws_db::Db,
} }
/// Returns `None` when no test DB is configured (test should early-return). /// Returns `None` when no test DB is configured (test should early-return).
@@ -54,11 +76,13 @@ pub async fn try_setup() -> Option<TestApp> {
let email = Arc::new(CapturingEmailSender::default()); let email = Arc::new(CapturingEmailSender::default());
let assets = Arc::new(InMemoryStore::default()); let assets = Arc::new(InMemoryStore::default());
let db_handle = db.clone();
let state = AppState { let state = AppState {
db, db,
cache: Arc::new(InMemoryCache::default()), cache: Arc::new(InMemoryCache::default()),
email: email.clone(), email: email.clone(),
assets: assets.clone(), assets: assets.clone(),
geo: Arc::new(FakeGeo),
jwt: JwtKeys::new("test-jwt-secret"), jwt: JwtKeys::new("test-jwt-secret"),
apple: Arc::new(NoopApple), apple: Arc::new(NoopApple),
apple_audience: "com.cardclaws.test".into(), apple_audience: "com.cardclaws.test".into(),
@@ -80,6 +104,7 @@ pub async fn try_setup() -> Option<TestApp> {
router: build_router(state), router: build_router(state),
email, email,
assets, assets,
db: db_handle,
}) })
} }
@@ -207,6 +232,37 @@ impl TestApp {
(status, content_type, bytes) (status, content_type, bytes)
} }
/// Like `request` but with extra request headers (e.g. `x-forwarded-for` to
/// exercise the geo path).
pub async fn request_with_headers(
&self,
method: &str,
path: &str,
token: Option<&str>,
body: Option<Value>,
headers: &[(&str, &str)],
) -> (StatusCode, Value) {
let mut builder = Request::builder().method(method).uri(path);
if let Some(t) = token {
builder = builder.header("authorization", format!("Bearer {t}"));
}
for (k, v) in headers {
builder = builder.header(*k, *v);
}
let req = match body {
Some(b) => builder
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&b).unwrap()))
.unwrap(),
None => builder.body(Body::empty()).unwrap(),
};
let resp = self.router.clone().oneshot(req).await.unwrap();
let status = resp.status();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let json: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
(status, json)
}
/// GET a path without following redirects; returns (status, Location header). /// GET a path without following redirects; returns (status, Location header).
pub async fn get_redirect(&self, path: &str) -> (StatusCode, Option<String>) { pub async fn get_redirect(&self, path: &str) -> (StatusCode, Option<String>) {
let req = Request::builder() let req = Request::builder()
@@ -14,6 +14,26 @@ pub struct AnalyticsSummary {
pub link_clicks: i64, pub link_clicks: i64,
} }
/// One hourly rollup bucket (PRD §9.3 `analytics_rollups_hourly`).
#[derive(Debug, Clone, FromRow, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RollupRow {
pub hour: DateTime<Utc>,
pub visits: i32,
pub qr_scans: i32,
pub nfc_taps: i32,
pub saves: i32,
pub link_clicks: i32,
}
/// A country's visit count for the geo distribution (PRD §6.7.1).
#[derive(Debug, Clone, FromRow, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GeoCount {
pub country: String,
pub visits: i64,
}
/// One row of the chronological event feed (PRD §6.7.2). /// One row of the chronological event feed (PRD §6.7.2).
#[derive(Debug, Clone, FromRow, Serialize)] #[derive(Debug, Clone, FromRow, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -5,7 +5,7 @@
use uuid::Uuid; use uuid::Uuid;
use crate::models::analytics::{AnalyticsSummary, FeedEvent}; use crate::models::analytics::{AnalyticsSummary, FeedEvent, GeoCount, RollupRow};
use crate::Db; use crate::Db;
pub struct NewEvent<'a> { pub struct NewEvent<'a> {
@@ -14,18 +14,23 @@ pub struct NewEvent<'a> {
/// Correlates the event to the share link that produced it (PRD §6.5.2). /// Correlates the event to the share link that produced it (PRD §6.5.2).
pub share_token: Option<&'a str>, pub share_token: Option<&'a str>,
pub ip_hash: Option<&'a str>, pub ip_hash: Option<&'a str>,
pub country: Option<&'a str>,
pub city: Option<&'a str>,
pub user_agent: Option<&'a str>, pub user_agent: Option<&'a str>,
} }
pub async fn insert_event(db: &Db, ev: NewEvent<'_>) -> Result<(), sqlx::Error> { pub async fn insert_event(db: &Db, ev: NewEvent<'_>) -> Result<(), sqlx::Error> {
sqlx::query( sqlx::query(
r#"INSERT INTO analytics_events (card_id, event_type, share_token, ip_hash, user_agent) r#"INSERT INTO analytics_events
VALUES ($1, $2, $3, $4, $5)"#, (card_id, event_type, share_token, ip_hash, country, city, user_agent)
VALUES ($1, $2, $3, $4, $5, $6, $7)"#,
) )
.bind(ev.card_id) .bind(ev.card_id)
.bind(ev.event_type) .bind(ev.event_type)
.bind(ev.share_token) .bind(ev.share_token)
.bind(ev.ip_hash) .bind(ev.ip_hash)
.bind(ev.country)
.bind(ev.city)
.bind(ev.user_agent) .bind(ev.user_agent)
.execute(db) .execute(db)
.await?; .await?;
@@ -66,3 +71,62 @@ pub async fn feed(db: &Db, card_id: Uuid, limit: i64) -> Result<Vec<FeedEvent>,
.fetch_all(db) .fetch_all(db)
.await .await
} }
/// Visit counts grouped by country, descending (PRD §6.7.1 geo distribution).
/// Rows with no resolved country are excluded.
pub async fn geo_breakdown(db: &Db, card_id: Uuid) -> Result<Vec<GeoCount>, sqlx::Error> {
sqlx::query_as(
r#"SELECT country, COUNT(*) AS visits
FROM analytics_events
WHERE card_id = $1 AND country IS NOT NULL
GROUP BY country
ORDER BY visits DESC"#,
)
.bind(card_id)
.fetch_all(db)
.await
}
/// Read a card's hourly rollup buckets, newest first.
pub async fn rollups(db: &Db, card_id: Uuid) -> Result<Vec<RollupRow>, sqlx::Error> {
sqlx::query_as(
r#"SELECT hour, visits, qr_scans, nfc_taps, saves, link_clicks
FROM analytics_rollups_hourly
WHERE card_id = $1
ORDER BY hour DESC"#,
)
.bind(card_id)
.fetch_all(db)
.await
}
/// Recompute the hourly rollups from the raw event log (PRD §18.2). Idempotent:
/// `ON CONFLICT DO UPDATE` overwrites each (card, hour) bucket, so re-running is
/// safe and self-correcting. Returns the number of buckets written.
pub async fn run_rollup(db: &Db) -> Result<u64, sqlx::Error> {
let result = sqlx::query(
r#"
INSERT INTO analytics_rollups_hourly
(card_id, hour, visits, qr_scans, nfc_taps, saves, link_clicks)
SELECT
card_id,
date_trunc('hour', occurred_at) AS hour,
COUNT(*) FILTER (WHERE event_type = 'profile_visit'),
COUNT(*) FILTER (WHERE event_type = 'qr_scan'),
COUNT(*) FILTER (WHERE event_type = 'nfc_tap'),
COUNT(*) FILTER (WHERE event_type = 'contact_save'),
COUNT(*) FILTER (WHERE event_type = 'link_click')
FROM analytics_events
GROUP BY card_id, date_trunc('hour', occurred_at)
ON CONFLICT (card_id, hour) DO UPDATE SET
visits = EXCLUDED.visits,
qr_scans = EXCLUDED.qr_scans,
nfc_taps = EXCLUDED.nfc_taps,
saves = EXCLUDED.saves,
link_clicks = EXCLUDED.link_clicks
"#,
)
.execute(db)
.await?;
Ok(result.rows_affected())
}