"We Met" scan experience — capture connections on scan
CI / policy (push) Has been cancelled
CI / backend (push) Has been cancelled
CI / profile (push) Has been cancelled
CI / mobile (push) Has been cancelled

Experience #1 from the scan-experiences brief: when someone scans a card and
shares back, capture the meeting (with coarse geo + time) so the owner gets a
"People I met" list.

Backend (cardclaws-backend):
- migration 0006_connections.sql: connections table (card_id, owner_id, name,
  email, note, country, city, met_at). Raw IP never stored — only resolved geo.
- POST /v1/profile/:handle/connect (public, rate-limited 5/min): captures the
  connection with geo, emails the owner best-effort.
- GET /v1/cards/:id/connections (owner-only): the People-I-met list.
- ConnectionRow model + connections queries (insert, list_by_card);
  profile_service::submit_connection; card_service::list_connections.
- 2 tests (capture→owner-list + validation, owner-only); gate green (118 tests).
- Verified live end-to-end.

Web (cardclaws-profile):
- [handle].astro: a "We met?" share-back form posting to /connect; astro check
  clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-05 18:36:09 -05:00
co-authored by Claude Opus 4.8
parent 5aba3ef1a1
commit 256f2285b9
12 changed files with 312 additions and 0 deletions
@@ -30,6 +30,17 @@ pub struct WelcomeRequest {
pub mood: Option<String>, pub mood: Option<String>,
} }
/// List the "We Met" connections captured for a card (owner only).
pub async fn list_connections(
State(state): State<AppState>,
user: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<Json<Vec<cardclaws_db::models::connection::ConnectionRow>>> {
Ok(Json(
card_service::list_connections(&state, id, user.user_id).await?,
))
}
/// Generate + attach an AI welcome (owner only). Shown when the QR is scanned. /// Generate + attach an AI welcome (owner only). Shown when the QR is scanned.
pub async fn set_welcome( pub async fn set_welcome(
State(state): State<AppState>, State(state): State<AppState>,
@@ -24,3 +24,32 @@ pub async fn submit_contact(
profile_service::submit_contact(&state, &handle, &req.name, &req.email, &req.message).await?; profile_service::submit_contact(&state, &handle, &req.name, &req.email, &req.message).await?;
Ok(Json(json!({ "status": "sent" }))) Ok(Json(json!({ "status": "sent" })))
} }
#[derive(Deserialize)]
pub struct ConnectRequest {
pub name: String,
#[serde(default)]
pub email: Option<String>,
#[serde(default)]
pub note: Option<String>,
}
/// "We Met" — a scanner shares back; capture the connection (with coarse geo).
pub async fn submit_connection(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Path(handle): Path<String>,
Json(req): Json<ConnectRequest>,
) -> ApiResult<Json<Value>> {
let ip = crate::handlers::analytics::client_ip(&headers);
profile_service::submit_connection(
&state,
&handle,
&req.name,
req.email.as_deref(),
req.note.as_deref(),
ip.as_deref(),
)
.await?;
Ok(Json(json!({ "status": "connected" })))
}
@@ -66,6 +66,8 @@ pub fn build_router(state: AppState) -> Router {
) )
.route("/analytics/event", post(analytics::ingest_event)) .route("/analytics/event", post(analytics::ingest_event))
.route("/cards/:id/welcome", post(cards::set_welcome)) .route("/cards/:id/welcome", post(cards::set_welcome))
.route("/cards/:id/connections", get(cards::list_connections))
.route("/profile/:handle/connect", post(profile::submit_connection))
.route("/ai/refine", post(ai::refine)) .route("/ai/refine", post(ai::refine))
.route("/ai/image", post(ai::image)) .route("/ai/image", post(ai::image))
.route("/ai/video", post(ai::start_video)) .route("/ai/video", post(ai::start_video))
@@ -57,6 +57,18 @@ fn ai_to_app(e: crate::ai::AiError) -> AppError {
AppError::Internal(e.to_string()) AppError::Internal(e.to_string())
} }
/// List the "We Met" connections captured for a card the caller owns.
pub async fn list_connections(
state: &AppState,
id: Uuid,
user_id: Uuid,
) -> Result<Vec<cardclaws_db::models::connection::ConnectionRow>, AppError> {
get_owned(state, id, user_id).await?;
cardclaws_db::queries::connections::list_by_card(&state.db, id)
.await
.map_db()
}
pub async fn list(state: &AppState, user_id: Uuid) -> Result<Vec<CardRow>, AppError> { pub async fn list(state: &AppState, user_id: Uuid) -> Result<Vec<CardRow>, AppError> {
cards::list_by_owner(&state.db, user_id).await.map_db() cards::list_by_owner(&state.db, user_id).await.map_db()
} }
@@ -54,6 +54,73 @@ pub async fn submit_contact(
Ok(()) Ok(())
} }
/// "We Met": a scanner shares back; capture the connection (with coarse geo) for
/// the card owner's "People I met" list, and email the owner. Rate-limited.
pub async fn submit_connection(
state: &AppState,
handle: &str,
name: &str,
email: Option<&str>,
note: Option<&str>,
ip: Option<&str>,
) -> Result<(), AppError> {
if name.trim().is_empty() {
return Err(AppError::Validation("name is required".into()));
}
if let Some(e) = email {
if !e.trim().is_empty() {
crate::validation::validate_email(e)?;
}
}
rate_limit::check(state.cache.as_ref(), &format!("connect:{handle}"), 5, 60).await?;
let card = card_service::get_public_by_handle(state, handle).await?;
// Resolve coarse geo, then drop the raw IP (PRD §18.3).
let geo = ip.map(|raw| state.geo.resolve(raw));
let country = geo.as_ref().and_then(|g| g.country.as_deref());
let city = geo.as_ref().and_then(|g| g.city.as_deref());
cardclaws_db::queries::connections::insert(
&state.db,
cardclaws_db::queries::connections::NewConnection {
card_id: card.id,
owner_id: card.owner_id,
name: name.trim(),
email: email.map(str::trim).filter(|e| !e.is_empty()),
note: note.map(str::trim).filter(|n| !n.is_empty()),
country,
city,
},
)
.await
.map_db()?;
// Notify the owner (best-effort).
if let Some(owner) = users::find_by_id(&state.db, card.owner_id).await.map_db()? {
let where_ = city
.map(|c| format!(" in {}", escape_html(c)))
.unwrap_or_default();
let note_html = note
.filter(|n| !n.trim().is_empty())
.map(|n| format!("<blockquote>{}</blockquote>", escape_html(n)))
.unwrap_or_default();
let html = format!(
"<p><strong>{}</strong> connected with you via CardClaws{}.</p>{}",
escape_html(name),
where_,
note_html,
);
let _ = state
.email
.send(&owner.email, "New CardClaws connection", &html)
.await;
}
Ok(())
}
/// Minimal HTML escaping so the visitor's text can't inject markup into the email. /// Minimal HTML escaping so the visitor's text can't inject markup into the email.
fn escape_html(s: &str) -> String { fn escape_html(s: &str) -> String {
s.replace('&', "&amp;") s.replace('&', "&amp;")
@@ -281,3 +281,69 @@ async fn welcome_requires_ownership() {
.await; .await;
assert_eq!(status, StatusCode::NOT_FOUND); assert_eq!(status, StatusCode::NOT_FOUND);
} }
#[tokio::test]
async fn we_met_capture_and_owner_list() {
let app = require_app!();
let token = app.register_and_token().await;
let (id, handle) = create_card(&app, &token).await;
app.request(
"POST",
&format!("/v1/cards/{id}/publish"),
Some(&token),
None,
)
.await;
// A scanner shares back (public, unauthenticated).
let (s, _) = app
.request(
"POST",
&format!("/v1/profile/{handle}/connect"),
None,
Some(json!({ "name": "Dana Scanner", "email": "[email protected]", "note": "met at SXSW" })),
)
.await;
assert_eq!(s, StatusCode::OK);
// The owner sees it in their connections.
let (s, body) = app
.request(
"GET",
&format!("/v1/cards/{id}/connections"),
Some(&token),
None,
)
.await;
assert_eq!(s, StatusCode::OK);
assert_eq!(body[0]["name"], "Dana Scanner");
assert_eq!(body[0]["note"], "met at SXSW");
// Name is required.
let (s, _) = app
.request(
"POST",
&format!("/v1/profile/{handle}/connect"),
None,
Some(json!({ "name": " " })),
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn connections_list_is_owner_only() {
let app = require_app!();
let token = app.register_and_token().await;
let (id, _handle) = create_card(&app, &token).await;
let other = app.register_and_token().await;
let (status, _) = app
.request(
"GET",
&format!("/v1/cards/{id}/connections"),
Some(&other),
None,
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
@@ -0,0 +1,17 @@
-- "We Met" scan experience (PRD §21 Phase 5): when someone scans a card and
-- shares back, we capture the meeting (with coarse geo + time) so the owner gets
-- a "People I met" list. Raw IP is never stored — only the resolved country/city.
CREATE TABLE connections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
card_id UUID NOT NULL REFERENCES cards(id) ON DELETE CASCADE,
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
email TEXT,
note TEXT,
country TEXT,
city TEXT,
met_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_connections_card ON connections(card_id, met_at DESC);
CREATE INDEX idx_connections_owner ON connections(owner_id, met_at DESC);
@@ -0,0 +1,19 @@
use chrono::{DateTime, Utc};
use serde::Serialize;
use sqlx::FromRow;
use uuid::Uuid;
/// A "We Met" connection — someone who scanned a card and shared back.
#[derive(Debug, Clone, FromRow, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ConnectionRow {
pub id: Uuid,
pub card_id: Uuid,
pub owner_id: Uuid,
pub name: String,
pub email: Option<String>,
pub note: Option<String>,
pub country: Option<String>,
pub city: Option<String>,
pub met_at: DateTime<Utc>,
}
@@ -3,6 +3,7 @@
pub mod analytics; pub mod analytics;
pub mod card; pub mod card;
pub mod connection;
pub mod session; pub mod session;
pub mod share_link; pub mod share_link;
pub mod team; pub mod team;
@@ -0,0 +1,46 @@
//! "We Met" connection queries.
use uuid::Uuid;
use crate::models::connection::ConnectionRow;
use crate::Db;
pub struct NewConnection<'a> {
pub card_id: Uuid,
pub owner_id: Uuid,
pub name: &'a str,
pub email: Option<&'a str>,
pub note: Option<&'a str>,
pub country: Option<&'a str>,
pub city: Option<&'a str>,
}
pub async fn insert(db: &Db, new: NewConnection<'_>) -> Result<ConnectionRow, sqlx::Error> {
sqlx::query_as(
r#"
INSERT INTO connections (card_id, owner_id, name, email, note, country, city)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, card_id, owner_id, name, email, note, country, city, met_at
"#,
)
.bind(new.card_id)
.bind(new.owner_id)
.bind(new.name)
.bind(new.email)
.bind(new.note)
.bind(new.country)
.bind(new.city)
.fetch_one(db)
.await
}
/// Most-recent-first connections captured for a card.
pub async fn list_by_card(db: &Db, card_id: Uuid) -> Result<Vec<ConnectionRow>, sqlx::Error> {
sqlx::query_as(
r#"SELECT id, card_id, owner_id, name, email, note, country, city, met_at
FROM connections WHERE card_id = $1 ORDER BY met_at DESC LIMIT 200"#,
)
.bind(card_id)
.fetch_all(db)
.await
}
@@ -3,6 +3,7 @@
pub mod analytics; pub mod analytics;
pub mod cards; pub mod cards;
pub mod connections;
pub mod export; pub mod export;
pub mod sessions; pub mod sessions;
pub mod share; pub mod share;
@@ -76,6 +76,18 @@ const og = `${name}${contact.title ? ` — ${contact.title}` : ""}${
</section> </section>
)} )}
<section class="block">
<h2>We met?</h2>
<p class="bio">Share your details so {name} remembers you.</p>
<form id="connect-form" class="contact-form">
<input name="name" placeholder="Your name" required />
<input name="email" type="email" placeholder="Your email (optional)" />
<input name="note" placeholder="Where/how we met (optional)" maxlength="200" />
<button type="submit">Connect</button>
<p id="connect-status" class="status"></p>
</form>
</section>
<section class="block"> <section class="block">
<h2>Get in touch</h2> <h2>Get in touch</h2>
<form id="contact-form" class="contact-form"> <form id="contact-form" class="contact-form">
@@ -254,5 +266,34 @@ const og = `${name}${contact.title ? ` — ${contact.title}` : ""}${
} }
}); });
</script> </script>
<script define:vars={{ handle, apiBase: publicApiBase() }}>
const cform = document.getElementById("connect-form");
const cstatus = document.getElementById("connect-status");
cform?.addEventListener("submit", async (e) => {
e.preventDefault();
const data = new FormData(cform);
cstatus.textContent = "Connecting…";
try {
const res = await fetch(`${apiBase}/v1/profile/${handle}/connect`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
name: data.get("name"),
email: data.get("email") || undefined,
note: data.get("note") || undefined,
}),
});
if (res.ok) {
cform.reset();
cstatus.textContent = "Connected — you're now in their contacts.";
} else {
cstatus.textContent = "Please check your details and try again.";
}
} catch {
cstatus.textContent = "Something went wrong. Try again later.";
}
});
</script>
</body> </body>
</html> </html>