Phase 2: share system + analytics feed (backend)
- 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:
co-authored by
Claude Opus 4.8
parent
c30d3afeec
commit
e66813ef58
@@ -82,7 +82,16 @@ bash scripts/policy-grep.sh # no stub/placeholder markers (PRD §15.4)
|
|||||||
Skia/Reanimated card viewer (3D flip + entry + ambient drift). Logic core
|
Skia/Reanimated card viewer (3D flip + entry + ambient drift). Logic core
|
||||||
unit-tested (22 tests: handle validation, k-means palette, vCard, undo/redo
|
unit-tested (22 tests: handle validation, k-means palette, vCard, undo/redo
|
||||||
store, QR); full app type-checks. Component/E2E runs need a simulator.
|
store, QR); full app type-checks. Component/E2E runs need a simulator.
|
||||||
- **B4** Apple Wallet + analytics (backend) — done. `cardclaws-wallet` crate
|
- **B4** Apple Wallet + analytics (backend) — done.
|
||||||
|
|
||||||
|
### Phase 2 (in progress)
|
||||||
|
|
||||||
|
- **Share system (backend)** — done. `POST /v1/cards/{id}/share` mints an opaque
|
||||||
|
12-char base62 token; `GET /v1/s/{token}` records the modality-attributed
|
||||||
|
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`
|
||||||
|
correlation, and `GET /v1/cards/{id}/analytics/feed` returns the chronological
|
||||||
|
feed. `cardclaws-wallet` crate
|
||||||
(pass.json builder, SHA-1 manifest, strip render, zip packager, PKCS#7
|
(pass.json builder, SHA-1 manifest, strip render, zip packager, PKCS#7
|
||||||
OpenSSL signer behind the `apple-signing` feature) wired at
|
OpenSSL signer behind the `apple-signing` feature) wired at
|
||||||
`POST /v1/cards/{id}/wallet/apple`. Analytics ingest + summary
|
`POST /v1/cards/{id}/wallet/apple`. Analytics ingest + summary
|
||||||
|
|||||||
@@ -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;
|
use cardclaws_db::models::analytics::{AnalyticsSummary, FeedEvent};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -17,6 +17,8 @@ use crate::state::AppState;
|
|||||||
pub struct IngestRequest {
|
pub struct IngestRequest {
|
||||||
pub card_id: Uuid,
|
pub card_id: Uuid,
|
||||||
pub event_type: String,
|
pub event_type: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub share_token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Public client-side event ingest (PRD §18.1 secondary path).
|
/// Public client-side event ingest (PRD §18.1 secondary path).
|
||||||
@@ -31,6 +33,7 @@ pub async fn ingest_event(
|
|||||||
&state,
|
&state,
|
||||||
req.card_id,
|
req.card_id,
|
||||||
&req.event_type,
|
&req.event_type,
|
||||||
|
req.share_token.as_deref(),
|
||||||
ip.as_deref(),
|
ip.as_deref(),
|
||||||
ua.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
|
/// 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> {
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ pub async fn get_card_by_handle(
|
|||||||
&state,
|
&state,
|
||||||
profile.card.id,
|
profile.card.id,
|
||||||
"profile_visit",
|
"profile_visit",
|
||||||
|
None,
|
||||||
ip.as_deref(),
|
ip.as_deref(),
|
||||||
ua.as_deref(),
|
ua.as_deref(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ pub mod assets;
|
|||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod cards;
|
pub mod cards;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
|
pub mod share;
|
||||||
pub mod wallet;
|
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::routing::{get, post};
|
||||||
use axum::Router;
|
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::middleware::cors;
|
||||||
use crate::state::AppState;
|
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/:id/wallet/apple", post(wallet::apple_pass))
|
||||||
.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/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("/analytics/event", post(analytics::ingest_event))
|
||||||
.route("/assets/upload", post(assets::presign_upload))
|
.route("/assets/upload", post(assets::presign_upload))
|
||||||
.route("/assets/*key", axum::routing::delete(assets::delete_asset));
|
.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
|
//! Analytics ingestion + summary (PRD §18). IPs are hashed with a per-day
|
||||||
//! rotating salt before storage — never persisted in plaintext (§18.3).
|
//! 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_db::queries::analytics;
|
||||||
use cardclaws_types::AppError;
|
use cardclaws_types::AppError;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
@@ -34,6 +34,7 @@ pub async fn ingest_client_event(
|
|||||||
state: &AppState,
|
state: &AppState,
|
||||||
card_id: Uuid,
|
card_id: Uuid,
|
||||||
event_type: &str,
|
event_type: &str,
|
||||||
|
share_token: Option<&str>,
|
||||||
ip: Option<&str>,
|
ip: Option<&str>,
|
||||||
user_agent: Option<&str>,
|
user_agent: Option<&str>,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
@@ -51,15 +52,17 @@ pub async fn ingest_client_event(
|
|||||||
)
|
)
|
||||||
.await?;
|
.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`).
|
/// Record any event type internally (used for server-originated events like
|
||||||
/// Errors are swallowed by callers that treat analytics as best-effort.
|
/// `profile_visit` and share-link resolutions). Errors are swallowed by callers
|
||||||
|
/// that treat analytics as best-effort.
|
||||||
pub async fn record(
|
pub async fn record(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
card_id: Uuid,
|
card_id: Uuid,
|
||||||
event_type: &str,
|
event_type: &str,
|
||||||
|
share_token: Option<&str>,
|
||||||
ip: Option<&str>,
|
ip: Option<&str>,
|
||||||
user_agent: Option<&str>,
|
user_agent: Option<&str>,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
@@ -70,6 +73,7 @@ pub async fn record(
|
|||||||
analytics::NewEvent {
|
analytics::NewEvent {
|
||||||
card_id,
|
card_id,
|
||||||
event_type,
|
event_type,
|
||||||
|
share_token,
|
||||||
ip_hash: ip_hash.as_deref(),
|
ip_hash: ip_hash.as_deref(),
|
||||||
user_agent,
|
user_agent,
|
||||||
},
|
},
|
||||||
@@ -88,6 +92,16 @@ pub async fn summary(
|
|||||||
analytics::summary(&state.db, card_id).await.map_db()
|
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
|
/// 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
|
/// hash cannot be correlated across days, while same-day uniqueness is
|
||||||
/// preserved for unique-visitor counting (§18.3).
|
/// preserved for unique-visitor counting (§18.3).
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod analytics_service;
|
pub mod analytics_service;
|
||||||
pub mod auth_service;
|
pub mod auth_service;
|
||||||
pub mod card_service;
|
pub mod card_service;
|
||||||
|
pub mod share_service;
|
||||||
pub mod vcard;
|
pub mod vcard;
|
||||||
pub mod wallet_service;
|
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",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -207,6 +207,22 @@ impl TestApp {
|
|||||||
(status, content_type, bytes)
|
(status, content_type, bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GET a path without following redirects; returns (status, Location header).
|
||||||
|
pub async fn get_redirect(&self, path: &str) -> (StatusCode, Option<String>) {
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("GET")
|
||||||
|
.uri(path)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = self.router.clone().oneshot(req).await.unwrap();
|
||||||
|
let location = resp
|
||||||
|
.headers()
|
||||||
|
.get("location")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
(resp.status(), location)
|
||||||
|
}
|
||||||
|
|
||||||
/// Register a fresh user and return its access token.
|
/// Register a fresh user and return its access token.
|
||||||
pub async fn register_and_token(&self) -> String {
|
pub async fn register_and_token(&self) -> String {
|
||||||
let (email, handle) = unique_identity();
|
let (email, handle) = unique_identity();
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
//! Integration tests for the share system (PRD §13.4, §17).
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use common::{unique_handle, TestApp};
|
||||||
|
|
||||||
|
async fn create_card(app: &TestApp, token: &str) -> String {
|
||||||
|
let handle = unique_handle();
|
||||||
|
let def = json!({
|
||||||
|
"face": { "layers": [], "background": { "type": "solid", "value": "#101014" } },
|
||||||
|
"back": { "layers": [] }
|
||||||
|
});
|
||||||
|
let (_, created) = app
|
||||||
|
.request(
|
||||||
|
"POST",
|
||||||
|
"/v1/cards",
|
||||||
|
Some(token),
|
||||||
|
Some(json!({"handle": handle, "definition": def})),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
created["id"].as_str().unwrap().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_share(
|
||||||
|
app: &TestApp,
|
||||||
|
token: &str,
|
||||||
|
card_id: &str,
|
||||||
|
modality: &str,
|
||||||
|
) -> (StatusCode, Value) {
|
||||||
|
app.request(
|
||||||
|
"POST",
|
||||||
|
&format!("/v1/cards/{card_id}/share"),
|
||||||
|
Some(token),
|
||||||
|
Some(json!({ "modality": modality })),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn create_share_link_returns_token_and_url() {
|
||||||
|
let app = require_app!();
|
||||||
|
let token = app.register_and_token().await;
|
||||||
|
let card_id = create_card(&app, &token).await;
|
||||||
|
|
||||||
|
let (status, body) = create_share(&app, &token, &card_id, "qr").await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
let tok = body["token"].as_str().unwrap();
|
||||||
|
assert_eq!(tok.len(), 12);
|
||||||
|
assert!(body["url"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.ends_with(&format!("/s/{tok}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn create_share_requires_auth_and_valid_modality() {
|
||||||
|
let app = require_app!();
|
||||||
|
let token = app.register_and_token().await;
|
||||||
|
let card_id = create_card(&app, &token).await;
|
||||||
|
|
||||||
|
let (unauth, _) = app
|
||||||
|
.request(
|
||||||
|
"POST",
|
||||||
|
&format!("/v1/cards/{card_id}/share"),
|
||||||
|
None,
|
||||||
|
Some(json!({"modality": "qr"})),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(unauth, StatusCode::UNAUTHORIZED);
|
||||||
|
|
||||||
|
let (bad, body) = create_share(&app, &token, &card_id, "telepathy").await;
|
||||||
|
assert_eq!(bad, StatusCode::BAD_REQUEST);
|
||||||
|
assert_eq!(body["code"], "validation");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn resolve_redirects_and_attributes_qr_scan() {
|
||||||
|
let app = require_app!();
|
||||||
|
let token = app.register_and_token().await;
|
||||||
|
let card_id = create_card(&app, &token).await;
|
||||||
|
let (_, share) = create_share(&app, &token, &card_id, "qr").await;
|
||||||
|
let tok = share["token"].as_str().unwrap();
|
||||||
|
|
||||||
|
let (status, location) = app.get_redirect(&format!("/v1/s/{tok}")).await;
|
||||||
|
assert_eq!(status, StatusCode::FOUND);
|
||||||
|
assert!(location.unwrap().starts_with("https://cardclaws.test/"));
|
||||||
|
|
||||||
|
// The resolution recorded a qr_scan attributed to the card.
|
||||||
|
let (_, summary) = app
|
||||||
|
.request(
|
||||||
|
"GET",
|
||||||
|
&format!("/v1/cards/{card_id}/analytics"),
|
||||||
|
Some(&token),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(summary["qrScans"], 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn feed_shows_attributed_event_after_resolve() {
|
||||||
|
let app = require_app!();
|
||||||
|
let token = app.register_and_token().await;
|
||||||
|
let card_id = create_card(&app, &token).await;
|
||||||
|
let (_, share) = create_share(&app, &token, &card_id, "qr").await;
|
||||||
|
let tok = share["token"].as_str().unwrap().to_string();
|
||||||
|
|
||||||
|
app.get_redirect(&format!("/v1/s/{tok}")).await;
|
||||||
|
|
||||||
|
let (status, body) = app
|
||||||
|
.request(
|
||||||
|
"GET",
|
||||||
|
&format!("/v1/cards/{card_id}/analytics/feed"),
|
||||||
|
Some(&token),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
let events = body.as_array().unwrap();
|
||||||
|
assert!(events
|
||||||
|
.iter()
|
||||||
|
.any(|e| e["eventType"] == "qr_scan" && e["shareToken"] == tok));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn resolve_unknown_token_is_404() {
|
||||||
|
let app = require_app!();
|
||||||
|
let (status, _) = app.get_redirect("/v1/s/doesnotexist1").await;
|
||||||
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_share_links_returns_created_links() {
|
||||||
|
let app = require_app!();
|
||||||
|
let token = app.register_and_token().await;
|
||||||
|
let card_id = create_card(&app, &token).await;
|
||||||
|
create_share(&app, &token, &card_id, "qr").await;
|
||||||
|
create_share(&app, &token, &card_id, "nfc").await;
|
||||||
|
|
||||||
|
let (status, body) = app
|
||||||
|
.request(
|
||||||
|
"GET",
|
||||||
|
&format!("/v1/cards/{card_id}/share-links"),
|
||||||
|
Some(&token),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
let arr = body.as_array().unwrap();
|
||||||
|
assert_eq!(arr.len(), 2);
|
||||||
|
let modalities: Vec<&str> = arr
|
||||||
|
.iter()
|
||||||
|
.map(|l| l["modality"].as_str().unwrap())
|
||||||
|
.collect();
|
||||||
|
assert!(modalities.contains(&"qr") && modalities.contains(&"nfc"));
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use sqlx::FromRow;
|
use sqlx::FromRow;
|
||||||
|
|
||||||
@@ -12,3 +13,15 @@ pub struct AnalyticsSummary {
|
|||||||
pub contact_saves: i64,
|
pub contact_saves: i64,
|
||||||
pub link_clicks: i64,
|
pub link_clicks: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One row of the chronological event feed (PRD §6.7.2).
|
||||||
|
#[derive(Debug, Clone, FromRow, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FeedEvent {
|
||||||
|
pub id: i64,
|
||||||
|
pub event_type: String,
|
||||||
|
pub share_token: Option<String>,
|
||||||
|
pub country: Option<String>,
|
||||||
|
pub city: Option<String>,
|
||||||
|
pub occurred_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,4 +4,5 @@
|
|||||||
pub mod analytics;
|
pub mod analytics;
|
||||||
pub mod card;
|
pub mod card;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
|
pub mod share_link;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::Serialize;
|
||||||
|
use sqlx::FromRow;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Raw `share_links` row (PRD §17.1). The token is an opaque base62 id; it
|
||||||
|
/// encodes nothing and resolves purely via DB lookup.
|
||||||
|
#[derive(Debug, Clone, FromRow, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ShareLinkRow {
|
||||||
|
pub token: String,
|
||||||
|
pub card_id: Uuid,
|
||||||
|
pub modality: String,
|
||||||
|
pub campaign: Option<String>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub expires_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
@@ -5,23 +5,26 @@
|
|||||||
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::models::analytics::AnalyticsSummary;
|
use crate::models::analytics::{AnalyticsSummary, FeedEvent};
|
||||||
use crate::Db;
|
use crate::Db;
|
||||||
|
|
||||||
pub struct NewEvent<'a> {
|
pub struct NewEvent<'a> {
|
||||||
pub card_id: Uuid,
|
pub card_id: Uuid,
|
||||||
pub event_type: &'a str,
|
pub event_type: &'a str,
|
||||||
|
/// Correlates the event to the share link that produced it (PRD §6.5.2).
|
||||||
|
pub share_token: Option<&'a str>,
|
||||||
pub ip_hash: Option<&'a str>,
|
pub ip_hash: 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, ip_hash, user_agent)
|
r#"INSERT INTO analytics_events (card_id, event_type, share_token, ip_hash, user_agent)
|
||||||
VALUES ($1, $2, $3, $4)"#,
|
VALUES ($1, $2, $3, $4, $5)"#,
|
||||||
)
|
)
|
||||||
.bind(ev.card_id)
|
.bind(ev.card_id)
|
||||||
.bind(ev.event_type)
|
.bind(ev.event_type)
|
||||||
|
.bind(ev.share_token)
|
||||||
.bind(ev.ip_hash)
|
.bind(ev.ip_hash)
|
||||||
.bind(ev.user_agent)
|
.bind(ev.user_agent)
|
||||||
.execute(db)
|
.execute(db)
|
||||||
@@ -48,3 +51,18 @@ pub async fn summary(db: &Db, card_id: Uuid) -> Result<AnalyticsSummary, sqlx::E
|
|||||||
.fetch_one(db)
|
.fetch_one(db)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Most recent events for a card, newest first (PRD §6.7.2).
|
||||||
|
pub async fn feed(db: &Db, card_id: Uuid, limit: i64) -> Result<Vec<FeedEvent>, sqlx::Error> {
|
||||||
|
sqlx::query_as(
|
||||||
|
r#"SELECT id, event_type, share_token, country, city, occurred_at
|
||||||
|
FROM analytics_events
|
||||||
|
WHERE card_id = $1
|
||||||
|
ORDER BY occurred_at DESC, id DESC
|
||||||
|
LIMIT $2"#,
|
||||||
|
)
|
||||||
|
.bind(card_id)
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,4 +4,5 @@
|
|||||||
pub mod analytics;
|
pub mod analytics;
|
||||||
pub mod cards;
|
pub mod cards;
|
||||||
pub mod sessions;
|
pub mod sessions;
|
||||||
|
pub mod share;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
//! Share-link queries (PRD §17).
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::models::share_link::ShareLinkRow;
|
||||||
|
use crate::Db;
|
||||||
|
|
||||||
|
pub struct NewShareLink<'a> {
|
||||||
|
pub token: &'a str,
|
||||||
|
pub card_id: Uuid,
|
||||||
|
pub modality: &'a str,
|
||||||
|
pub campaign: Option<&'a str>,
|
||||||
|
pub expires_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn insert(db: &Db, new: NewShareLink<'_>) -> Result<ShareLinkRow, sqlx::Error> {
|
||||||
|
sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
INSERT INTO share_links (token, card_id, modality, campaign, expires_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING token, card_id, modality, campaign, created_at, expires_at
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(new.token)
|
||||||
|
.bind(new.card_id)
|
||||||
|
.bind(new.modality)
|
||||||
|
.bind(new.campaign)
|
||||||
|
.bind(new.expires_at)
|
||||||
|
.fetch_one(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_by_token(db: &Db, token: &str) -> Result<Option<ShareLinkRow>, sqlx::Error> {
|
||||||
|
sqlx::query_as(
|
||||||
|
r#"SELECT token, card_id, modality, campaign, created_at, expires_at
|
||||||
|
FROM share_links WHERE token = $1"#,
|
||||||
|
)
|
||||||
|
.bind(token)
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_by_card(db: &Db, card_id: Uuid) -> Result<Vec<ShareLinkRow>, sqlx::Error> {
|
||||||
|
sqlx::query_as(
|
||||||
|
r#"SELECT token, card_id, modality, campaign, created_at, expires_at
|
||||||
|
FROM share_links WHERE card_id = $1 ORDER BY created_at DESC"#,
|
||||||
|
)
|
||||||
|
.bind(card_id)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user