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
@@ -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).