Phase 2: share system + analytics feed (backend)
CI / policy (push) Successful in 4s
CI / backend (push) Failing after 44s
CI / mobile (push) Successful in 49s
CI / profile (push) Successful in 2m19s

- share_links model/queries; opaque 12-char base62 tokens (PRD §17.1)
- POST /v1/cards/{id}/share — create share link (owner-scoped, modality-validated)
- GET /v1/s/{token} — record modality-attributed event (qr→qr_scan, nfc→nfc_tap)
  and 302-redirect to the profile; unknown/expired → 404
- GET /v1/cards/{id}/share-links — list a card's links
- analytics_events now carry the share_token correlation
- GET /v1/cards/{id}/analytics/feed — chronological event feed (§6.7.2)

6 new integration tests; 73 backend tests total. fmt + clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-04 11:19:08 -05:00
co-authored by Claude Opus 4.8
parent c30d3afeec
commit e66813ef58
17 changed files with 527 additions and 10 deletions
@@ -3,7 +3,7 @@
use axum::extract::{Path, State};
use axum::http::HeaderMap;
use axum::Json;
use cardclaws_db::models::analytics::AnalyticsSummary;
use cardclaws_db::models::analytics::{AnalyticsSummary, FeedEvent};
use serde::Deserialize;
use serde_json::{json, Value};
use uuid::Uuid;
@@ -17,6 +17,8 @@ use crate::state::AppState;
pub struct IngestRequest {
pub card_id: Uuid,
pub event_type: String,
#[serde(default)]
pub share_token: Option<String>,
}
/// Public client-side event ingest (PRD §18.1 secondary path).
@@ -31,6 +33,7 @@ pub async fn ingest_event(
&state,
req.card_id,
&req.event_type,
req.share_token.as_deref(),
ip.as_deref(),
ua.as_deref(),
)
@@ -49,6 +52,17 @@ pub async fn summary(
))
}
/// Owner-only chronological event feed.
pub async fn feed(
State(state): State<AppState>,
user: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<Json<Vec<FeedEvent>>> {
Ok(Json(
analytics_service::feed(&state, id, user.user_id).await?,
))
}
/// Best-effort client IP from the proxy headers Cloudflare/Hetzner set. The
/// first IP in `x-forwarded-for` is the original client.
pub fn client_ip(headers: &HeaderMap) -> Option<String> {
@@ -136,6 +136,7 @@ pub async fn get_card_by_handle(
&state,
profile.card.id,
"profile_visit",
None,
ip.as_deref(),
ua.as_deref(),
)
@@ -3,4 +3,5 @@ pub mod assets;
pub mod auth;
pub mod cards;
pub mod health;
pub mod share;
pub mod wallet;
@@ -0,0 +1,69 @@
//! Share-link handlers (PRD §13.4).
use axum::extract::{Path, State};
use axum::http::{header, HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use cardclaws_db::models::share_link::ShareLinkRow;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::ApiResult;
use crate::handlers::analytics::{client_ip, header_str};
use crate::middleware::auth::AuthUser;
use crate::services::share_service;
use crate::state::AppState;
#[derive(Deserialize)]
pub struct CreateShareRequest {
pub modality: String,
#[serde(default)]
pub campaign: Option<String>,
}
#[derive(Serialize)]
pub struct CreateShareResponse {
pub token: String,
/// The short link the recipient opens, e.g. `https://cardclaws.com/s/ab12…`.
pub url: String,
}
pub async fn create_share(
State(state): State<AppState>,
user: AuthUser,
Path(id): Path<Uuid>,
Json(req): Json<CreateShareRequest>,
) -> ApiResult<Json<CreateShareResponse>> {
let link = share_service::create(
&state,
id,
user.user_id,
&req.modality,
req.campaign.as_deref(),
)
.await?;
Ok(Json(CreateShareResponse {
url: format!("{}/s/{}", state.profile_base_url, link.token),
token: link.token,
}))
}
pub async fn list_shares(
State(state): State<AppState>,
user: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<Json<Vec<ShareLinkRow>>> {
Ok(Json(share_service::list(&state, id, user.user_id).await?))
}
/// Public: resolve a token, record the attributed event, and 302 to the profile.
pub async fn resolve_share(
State(state): State<AppState>,
headers: HeaderMap,
Path(token): Path<String>,
) -> ApiResult<Response> {
let ip = client_ip(&headers);
let ua = header_str(&headers, "user-agent");
let target = share_service::resolve(&state, &token, ip.as_deref(), ua.as_deref()).await?;
Ok((StatusCode::FOUND, [(header::LOCATION, target)]).into_response())
}
@@ -4,7 +4,7 @@
use axum::routing::{get, post};
use axum::Router;
use crate::handlers::{analytics, assets, auth, cards, health, wallet};
use crate::handlers::{analytics, assets, auth, cards, health, share, wallet};
use crate::middleware::cors;
use crate::state::AppState;
@@ -34,6 +34,10 @@ pub fn build_router(state: AppState) -> Router {
.route("/cards/:id/wallet/apple", post(wallet::apple_pass))
.route("/cards/handle/:handle", get(cards::get_card_by_handle))
.route("/cards/:id/analytics", get(analytics::summary))
.route("/cards/:id/analytics/feed", get(analytics::feed))
.route("/cards/:id/share", post(share::create_share))
.route("/cards/:id/share-links", get(share::list_shares))
.route("/s/:token", get(share::resolve_share))
.route("/analytics/event", post(analytics::ingest_event))
.route("/assets/upload", post(assets::presign_upload))
.route("/assets/*key", axum::routing::delete(assets::delete_asset));
@@ -1,7 +1,7 @@
//! Analytics ingestion + summary (PRD §18). IPs are hashed with a per-day
//! rotating salt before storage — never persisted in plaintext (§18.3).
use cardclaws_db::models::analytics::AnalyticsSummary;
use cardclaws_db::models::analytics::{AnalyticsSummary, FeedEvent};
use cardclaws_db::queries::analytics;
use cardclaws_types::AppError;
use sha2::{Digest, Sha256};
@@ -34,6 +34,7 @@ pub async fn ingest_client_event(
state: &AppState,
card_id: Uuid,
event_type: &str,
share_token: Option<&str>,
ip: Option<&str>,
user_agent: Option<&str>,
) -> Result<(), AppError> {
@@ -51,15 +52,17 @@ pub async fn ingest_client_event(
)
.await?;
record(state, card_id, event_type, ip, user_agent).await
record(state, card_id, event_type, share_token, ip, user_agent).await
}
/// Record any event type internally (used for server-originated `profile_visit`).
/// Errors are swallowed by callers that treat analytics as best-effort.
/// Record any event type internally (used for server-originated events like
/// `profile_visit` and share-link resolutions). Errors are swallowed by callers
/// that treat analytics as best-effort.
pub async fn record(
state: &AppState,
card_id: Uuid,
event_type: &str,
share_token: Option<&str>,
ip: Option<&str>,
user_agent: Option<&str>,
) -> Result<(), AppError> {
@@ -70,6 +73,7 @@ pub async fn record(
analytics::NewEvent {
card_id,
event_type,
share_token,
ip_hash: ip_hash.as_deref(),
user_agent,
},
@@ -88,6 +92,16 @@ pub async fn summary(
analytics::summary(&state.db, card_id).await.map_db()
}
/// Owner-only chronological event feed (capped).
pub async fn feed(
state: &AppState,
card_id: Uuid,
user_id: Uuid,
) -> Result<Vec<FeedEvent>, AppError> {
card_service::get_owned(state, card_id, user_id).await?;
analytics::feed(&state.db, card_id, 100).await.map_db()
}
/// SHA-256 of `date:secret:ip`. The date component rotates the salt daily so a
/// hash cannot be correlated across days, while same-day uniqueness is
/// preserved for unique-visitor counting (§18.3).
@@ -1,5 +1,6 @@
pub mod analytics_service;
pub mod auth_service;
pub mod card_service;
pub mod share_service;
pub mod vcard;
pub mod wallet_service;
@@ -0,0 +1,127 @@
//! Share-link creation + resolution (PRD §6.5.2, §17.1). Tokens are opaque
//! 12-char base62 ids that resolve via DB lookup — they encode nothing, so the
//! token structure leaks nothing.
use chrono::Utc;
use rand::Rng;
use uuid::Uuid;
use cardclaws_db::models::share_link::ShareLinkRow;
use cardclaws_db::queries::{cards, share};
use cardclaws_types::AppError;
use crate::error::SqlxResultExt;
use crate::services::{analytics_service, card_service};
use crate::state::AppState;
const BASE62: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
const TOKEN_LEN: usize = 12;
/// Share modalities (PRD §6.5.1). The modality is recorded so analytics can
/// attribute a visit to how it was shared.
const MODALITIES: &[&str] = &[
"nfc", "qr", "airdrop", "imessage", "email", "link", "wallet", "contact",
];
fn gen_token() -> String {
let mut rng = rand::thread_rng();
(0..TOKEN_LEN)
.map(|_| BASE62[rng.gen_range(0..BASE62.len())] as char)
.collect()
}
pub async fn create(
state: &AppState,
card_id: Uuid,
user_id: Uuid,
modality: &str,
campaign: Option<&str>,
) -> Result<ShareLinkRow, AppError> {
card_service::get_owned(state, card_id, user_id).await?;
if !MODALITIES.contains(&modality) {
return Err(AppError::Validation(format!(
"unknown modality: {modality}"
)));
}
// Retry on the (vanishingly unlikely) token collision.
for _ in 0..5 {
let token = gen_token();
match share::insert(
&state.db,
share::NewShareLink {
token: &token,
card_id,
modality,
campaign,
expires_at: None,
},
)
.await
{
Ok(row) => return Ok(row),
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => continue,
Err(e) => return Err(AppError::Internal(format!("db: {e}"))),
}
}
Err(AppError::Internal(
"could not allocate a share token".into(),
))
}
pub async fn list(
state: &AppState,
card_id: Uuid,
user_id: Uuid,
) -> Result<Vec<ShareLinkRow>, AppError> {
card_service::get_owned(state, card_id, user_id).await?;
share::list_by_card(&state.db, card_id).await.map_db()
}
/// Resolve a share token: record the attributed analytics event and return the
/// profile URL to redirect to. Unknown or expired tokens are `NotFound`.
pub async fn resolve(
state: &AppState,
token: &str,
ip: Option<&str>,
user_agent: Option<&str>,
) -> Result<String, AppError> {
let link = share::find_by_token(&state.db, token)
.await
.map_db()?
.ok_or_else(|| AppError::NotFound("share link".into()))?;
if let Some(exp) = link.expires_at {
if exp < Utc::now() {
return Err(AppError::NotFound("share link".into()));
}
}
let card = cards::find_by_id(&state.db, link.card_id)
.await
.map_db()?
.ok_or_else(|| AppError::NotFound("card".into()))?;
// Best-effort attributed event; never block the redirect on analytics.
let _ = analytics_service::record(
state,
link.card_id,
event_type_for(&link.modality),
Some(token),
ip,
user_agent,
)
.await;
Ok(format!("{}/{}", state.profile_base_url, card.handle))
}
/// Map a share modality to the analytics event type it produces (PRD §12.2).
fn event_type_for(modality: &str) -> &'static str {
match modality {
"qr" => "qr_scan",
"nfc" => "nfc_tap",
_ => "profile_visit",
}
}