AI Welcome scan experience — backend + web profile
CI / backend (push) Has been cancelled
CI / profile (push) Has been cancelled
CI / mobile (push) Has been cancelled
CI / policy (push) Has been cancelled

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:
Omar Sobh
2026-06-05 18:21:27 -05:00
co-authored by Claude Opus 4.8
parent a66978910d
commit 5aba3ef1a1
10 changed files with 272 additions and 6 deletions
@@ -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)