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
+10 -1
View File
@@ -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
}