Phase 2: analytics rollups, geo breakdown, event feed (backend)
- 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:
co-authored by
Claude Opus 4.8
parent
e66813ef58
commit
4770efd861
@@ -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.
|
||||||
|
|||||||
Generated
+22
@@ -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())
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user