Phase 1 foundation: backend, web profile, and mobile app
Greenfield implementation of CardClaws Phase 1 across three surfaces. Backend (Rust/Axum workspace, 67 tests): - cardclaws-types/config/db/auth/api/wallet crates - Auth: register, login + lockout, magic link, refresh rotation, Apple verify - Cards: CRUD, tier-limited publish, duplicate, public handle lookup - Assets: R2 presigned uploads; vCard export - Apple Wallet .pkpass pipeline (PKCS#7 signer behind apple-signing feature) - Analytics ingest + summary with daily-salted IP hashing - Migrations 0001 (incl. cardclaws_sessions) + 0002 analytics Web profile (Astro SSR): cardclaws.com/[handle] hero + flip, contact actions, client-built vCard, visit attribution. Verified end-to-end. Mobile (Expo SDK 51): auth, card list/create, builder v1 (bg/text/logo, palette, undo/redo), Skia/Reanimated viewer (flip + ambient). 22 logic tests. CI: policy/backend/profile/mobile jobs; LOC + no-placeholder lint. Review fixes baked in: `back` (not `cardclaws`) key; strip image is a bundled manifest file (not a URL); sessions table added; NFC reframed; test doubles allowed for external services. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
//! 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::queries::analytics;
|
||||
use cardclaws_types::AppError;
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::SqlxResultExt;
|
||||
use crate::middleware::rate_limit;
|
||||
use crate::services::card_service;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Client-ingestible event types (PRD §12.2). Server-originated types
|
||||
/// (`profile_visit`) are recorded internally and not accepted from clients.
|
||||
const CLIENT_EVENT_TYPES: &[&str] = &["qr_scan", "contact_save", "link_click", "wallet_add"];
|
||||
|
||||
/// All recordable event types (client + server-originated).
|
||||
const ALL_EVENT_TYPES: &[&str] = &[
|
||||
"profile_visit",
|
||||
"qr_scan",
|
||||
"nfc_tap",
|
||||
"contact_save",
|
||||
"link_click",
|
||||
"wallet_add",
|
||||
"contact_form_submission",
|
||||
"card_share_event",
|
||||
];
|
||||
|
||||
/// Record a client-reported event (PRD §18.1 secondary path). Rate-limited per
|
||||
/// card. Rejects server-only event types.
|
||||
pub async fn ingest_client_event(
|
||||
state: &AppState,
|
||||
card_id: Uuid,
|
||||
event_type: &str,
|
||||
ip: Option<&str>,
|
||||
user_agent: Option<&str>,
|
||||
) -> Result<(), AppError> {
|
||||
if !CLIENT_EVENT_TYPES.contains(&event_type) {
|
||||
return Err(AppError::Validation(format!(
|
||||
"event_type '{event_type}' is not client-ingestible"
|
||||
)));
|
||||
}
|
||||
// 100 events/min per card (§20.4).
|
||||
rate_limit::check(
|
||||
state.cache.as_ref(),
|
||||
&format!("analytics:ingest:{card_id}"),
|
||||
100,
|
||||
60,
|
||||
)
|
||||
.await?;
|
||||
|
||||
record(state, card_id, event_type, 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.
|
||||
pub async fn record(
|
||||
state: &AppState,
|
||||
card_id: Uuid,
|
||||
event_type: &str,
|
||||
ip: Option<&str>,
|
||||
user_agent: Option<&str>,
|
||||
) -> Result<(), AppError> {
|
||||
debug_assert!(ALL_EVENT_TYPES.contains(&event_type));
|
||||
let ip_hash = ip.map(|raw| hash_ip(&state.ip_hash_secret, raw));
|
||||
analytics::insert_event(
|
||||
&state.db,
|
||||
analytics::NewEvent {
|
||||
card_id,
|
||||
event_type,
|
||||
ip_hash: ip_hash.as_deref(),
|
||||
user_agent,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_db()
|
||||
}
|
||||
|
||||
/// Owner-only metrics summary for a card.
|
||||
pub async fn summary(
|
||||
state: &AppState,
|
||||
card_id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> Result<AnalyticsSummary, AppError> {
|
||||
card_service::get_owned(state, card_id, user_id).await?;
|
||||
analytics::summary(&state.db, card_id).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).
|
||||
fn hash_ip(secret: &str, ip: &str) -> String {
|
||||
let day = chrono::Utc::now().format("%Y-%m-%d");
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(format!("{day}:{secret}:{ip}").as_bytes());
|
||||
hasher
|
||||
.finalize()
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ip_hash_is_stable_within_day_and_ip_specific() {
|
||||
let a = hash_ip("seed", "1.2.3.4");
|
||||
let b = hash_ip("seed", "1.2.3.4");
|
||||
let c = hash_ip("seed", "5.6.7.8");
|
||||
assert_eq!(a, b);
|
||||
assert_ne!(a, c);
|
||||
assert_eq!(a.len(), 64);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user