"We Met" scan experience — capture connections on scan
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:
co-authored by
Claude Opus 4.8
parent
5aba3ef1a1
commit
256f2285b9
@@ -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('&', "&")
|
s.replace('&', "&")
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user