AI Welcome scan experience — backend + web profile
When a card is scanned, the public profile can open with a full-bleed, AI-generated welcome hero before the contact info — experience #10 from the scan-experiences brief, on the share-token experience selector foundation. Backend (cardclaws-backend): - migration 0005_welcome.sql: add cards.welcome JSONB; flows through PublicProfile via serde flatten. - POST /v1/cards/:id/welcome (owner-gated): refine + generate via the existing AiClient (nano-banana), store {kind, message, imageDataUrl}. - card_service::set_welcome + queries::update_welcome; CardRow gains `welcome`. - 2 tests (generate→public-profile, ownership); workspace gate green (116 tests). - Verified live with real Gemini end-to-end. Web (cardclaws-profile): - WelcomeHero.astro: full-bleed AI hero + greeting + "scroll to connect" cue. - [handle].astro renders it when the card has a welcome; experience selector (?x=profile suppresses) as the lever for the other experiences. - PublicProfile gains an optional `welcome`; astro check clean. MVP inlines the image as a data URL; production swaps to an R2 URL (isolated in the welcome column). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
a66978910d
commit
5aba3ef1a1
@@ -0,0 +1,5 @@
|
||||
-- AI Welcome (PRD §21 Phase 5, "scan experiences"): an optional generated
|
||||
-- greeting shown when the public profile is scanned. Stored as JSONB:
|
||||
-- { "kind": "image", "message": "...", "imageDataUrl": "data:image/png;base64,..." }
|
||||
-- (image inlined as a data URL for the MVP; swaps to an R2 URL later).
|
||||
ALTER TABLE cards ADD COLUMN welcome JSONB;
|
||||
@@ -17,4 +17,7 @@ pub struct CardRow {
|
||||
pub version: i32,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
/// Optional AI welcome shown on scan: { kind, message, imageDataUrl }.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub welcome: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ pub async fn insert(db: &Db, new: NewCard<'_>) -> Result<CardRow, sqlx::Error> {
|
||||
INSERT INTO cards (owner_id, handle, definition)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, owner_id, handle, status, definition, version,
|
||||
created_at, updated_at
|
||||
created_at, updated_at, welcome
|
||||
"#,
|
||||
)
|
||||
.bind(new.owner_id)
|
||||
@@ -29,7 +29,7 @@ pub async fn insert(db: &Db, new: NewCard<'_>) -> Result<CardRow, sqlx::Error> {
|
||||
|
||||
pub async fn find_by_id(db: &Db, id: Uuid) -> Result<Option<CardRow>, sqlx::Error> {
|
||||
sqlx::query_as(
|
||||
r#"SELECT id, owner_id, handle, status, definition, version, created_at, updated_at
|
||||
r#"SELECT id, owner_id, handle, status, definition, version, created_at, updated_at, welcome
|
||||
FROM cards WHERE id = $1"#,
|
||||
)
|
||||
.bind(id)
|
||||
@@ -39,7 +39,7 @@ pub async fn find_by_id(db: &Db, id: Uuid) -> Result<Option<CardRow>, sqlx::Erro
|
||||
|
||||
pub async fn list_by_owner(db: &Db, owner_id: Uuid) -> Result<Vec<CardRow>, sqlx::Error> {
|
||||
sqlx::query_as(
|
||||
r#"SELECT id, owner_id, handle, status, definition, version, created_at, updated_at
|
||||
r#"SELECT id, owner_id, handle, status, definition, version, created_at, updated_at, welcome
|
||||
FROM cards WHERE owner_id = $1 ORDER BY created_at DESC"#,
|
||||
)
|
||||
.bind(owner_id)
|
||||
@@ -51,7 +51,7 @@ pub async fn list_by_owner(db: &Db, owner_id: Uuid) -> Result<Vec<CardRow>, sqlx
|
||||
/// review A2 — the profile resolves the active card's handle).
|
||||
pub async fn find_active_by_handle(db: &Db, handle: &str) -> Result<Option<CardRow>, sqlx::Error> {
|
||||
sqlx::query_as(
|
||||
r#"SELECT id, owner_id, handle, status, definition, version, created_at, updated_at
|
||||
r#"SELECT id, owner_id, handle, status, definition, version, created_at, updated_at, welcome
|
||||
FROM cards WHERE handle = $1 AND status = 'active'"#,
|
||||
)
|
||||
.bind(handle)
|
||||
@@ -80,7 +80,7 @@ pub async fn update_definition(
|
||||
UPDATE cards
|
||||
SET definition = $2, version = version + 1, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, owner_id, handle, status, definition, version, created_at, updated_at
|
||||
RETURNING id, owner_id, handle, status, definition, version, created_at, updated_at, welcome
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
@@ -89,12 +89,31 @@ pub async fn update_definition(
|
||||
.await
|
||||
}
|
||||
|
||||
/// Set (or clear) the AI welcome shown on scan.
|
||||
pub async fn update_welcome(
|
||||
db: &Db,
|
||||
id: Uuid,
|
||||
welcome: &serde_json::Value,
|
||||
) -> Result<CardRow, sqlx::Error> {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
UPDATE cards SET welcome = $2, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, owner_id, handle, status, definition, version, created_at, updated_at, welcome
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(welcome)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_status(db: &Db, id: Uuid, status: &str) -> Result<CardRow, sqlx::Error> {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
UPDATE cards SET status = $2, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, owner_id, handle, status, definition, version, created_at, updated_at
|
||||
RETURNING id, owner_id, handle, status, definition, version, created_at, updated_at, welcome
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
|
||||
Reference in New Issue
Block a user