Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion api/src/api/http/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -729,7 +729,18 @@ pub async fn batch_create_claim_tokens(

// Send email if requested
if let (Some(email), Some(svc)) = (&req.delivery_email, &email_service) {
if let Err(e) = svc.send_claim_email(email, &claim_url).await {
// Best-effort kind-0 fetch for the preloaded account so the email
// can show the restaurant's display name + logo on the claim card.
// Falls back to the plain layout on any failure.
let relays = keycast_core::types::authorization::Authorization::get_bunker_relays();
let profile = crate::nostr_profile::fetch_profile_metadata(&user_pubkey, &relays).await;
let account_display_name = profile.as_ref().and_then(|p| p.display_name.as_deref());
let account_picture = profile.as_ref().and_then(|p| p.picture.as_deref());

if let Err(e) = svc
.send_claim_email(email, &claim_url, account_display_name, account_picture)
.await
{
tracing::warn!(
"Failed to send claim email for vine_id={} to {}: {}",
vine_id,
Expand Down
57 changes: 54 additions & 3 deletions api/src/api/http/teams.rs
Original file line number Diff line number Diff line change
Expand Up @@ -659,12 +659,61 @@ pub async fn invite_user(
)
.await?;

// Resolve inviter display name
// Resolve inviter display name (used for the list response regardless of email outcome)
let inviter_name = resolve_display_name(&pool, &user_pubkey_hex, tenant_id).await;

// For the email body we prefer a real display name → email → pubkey prefix.
// The admin's email is the most useful fallback when no kind-0 display name exists.
let inviter_real_display: Option<String> =
sqlx::query_as::<_, (Option<String>, Option<String>)>(
"SELECT display_name, username FROM users WHERE pubkey = $1 AND tenant_id = $2",
)
.bind(&user_pubkey_hex)
.bind(tenant_id)
.fetch_optional(&pool)
.await
.ok()
.flatten()
.and_then(|(dn, un)| {
dn.filter(|s| !s.is_empty())
.or_else(|| un.filter(|s| !s.is_empty()))
});
let inviter_email_opt = user_repo.get_email(&user_pubkey_hex, tenant_id).await.ok();
let inviter_label = inviter_real_display
.clone()
.or_else(|| inviter_email_opt.clone())
.unwrap_or_else(|| inviter_name.clone());

// Resolve team name
let team = team_repo.find(tenant_id, team_id).await?;

// Resolve team's primary stored key (used for kind-0 profile lookup).
let team_key_pubkey: Option<String> = sqlx::query_scalar(
"SELECT pubkey FROM stored_keys
WHERE tenant_id = $1 AND team_id = $2
ORDER BY id ASC
LIMIT 1",
)
.bind(tenant_id)
.bind(team_id)
.fetch_optional(&pool)
.await
.map_err(|e| ApiError::internal(format!("Failed to load team key: {}", e)))?;

// Best-effort kind-0 fetch so the email can show the team's real display
// name and avatar (same as the accept page). Falls back to the DB handle.
let profile = if let Some(ref pk) = team_key_pubkey {
let relays = keycast_core::types::authorization::Authorization::get_bunker_relays();
crate::nostr_profile::fetch_profile_metadata(pk, &relays).await
} else {
None
};
let team_display_name = profile
.as_ref()
.and_then(|p| p.display_name.clone())
.unwrap_or_else(|| team.name.clone());
let team_picture = profile.as_ref().and_then(|p| p.picture.clone());

// Build invite URL
let invite_base_url = std::env::var("INVITE_BASE_URL")
.or_else(|_| std::env::var("BASE_URL"))
Expand All @@ -678,9 +727,11 @@ pub async fn invite_user(
if let Err(e) = email_service
.send_team_invite_email(
&email,
&team.name,
&inviter_name,
&team_display_name,
team_picture.as_deref(),
&inviter_label,
role_str,
invitation.expires_at,
&invite_url,
)
.await
Expand Down
Loading
Loading