Skip to main content

Notification System

Push notifications are delivered via Firebase Cloud Messaging (FCM), with platform-specific payloads for iOS and Android.

Key Files

  • crates/bf_user/src/notifications.rs - Notification processing
  • crates/bf_user/src/fcm.rs - FCM integration
  • crates/bf_user/src/storage_sqlx.rs - Device token storage

Architecture

┌─────────────────────────────────────────────────────────────────────────┐
│ Smart Event (Webhook) │
└─────────────────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────────────┐
│ User DO │
│ │
│ process_side_effects() │
│ │ │
│ ├── Create Notification in DB │
│ ├── Get all device tokens for property │
│ └── Send FCM push to each token │
│ │
└─────────────────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────────────┐
│ bf_auth │
│ │
│ /service/firebase/token │
│ └── Returns OAuth2 access token for FCM │
│ │
└─────────────────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────────────┐
│ Firebase Cloud Messaging │
│ │
│ POST /v1/projects/{project}/messages:send │
│ ├── Android: channels, priority, sound │
│ └── iOS: interruption-level, critical sound │
│ │
└─────────────────────────────────────────────────────────────────────────┘

Notification Types

#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum NotificationType {
// Critical (time-sensitive)
AlarmTriggered,

// Alert (normal priority)
DoorAjar,
DoorAccess,
PinAccess,
SecurityModeChange,
TemperatureAlert,
EnergyAlert,
Maintenance,
GuestAccess,
DoorbellPressed,

// Informational
Announcement,
SystemUpdate,
}

Push Types

// crates/bf_user/src/fcm.rs

pub enum PushType {
Silent, // Background update only, no visible notification
Alert, // Standard visible notification
Critical, // Time-sensitive, bypasses DND (iOS only)
}

impl From<&NotificationType> for PushType {
fn from(t: &NotificationType) -> Self {
match t {
NotificationType::AlarmTriggered => PushType::Critical,
NotificationType::DoorAjar
| NotificationType::DoorAccess
| NotificationType::PinAccess
| NotificationType::SecurityModeChange
| NotificationType::DoorbellPressed => PushType::Alert,
_ => PushType::Alert,
}
}
}

FCM Message Structure

Android Payload

struct FcmMessage {
token: String,
notification: Option<Notification>,
data: HashMap<String, String>,
android: AndroidConfig,
}

struct AndroidConfig {
priority: String, // "high" or "normal"
notification: AndroidNotification,
}

struct AndroidNotification {
channel_id: String, // "alerts", "critical", etc.
sound: Option<String>, // "default" or custom
default_sound: bool,
default_vibrate_timings: bool,
}

iOS Payload

struct ApnsConfig {
headers: ApnsHeaders,
payload: ApnsPayload,
}

struct ApnsHeaders {
apns_priority: String, // "10" (alert) or "5" (background)
apns_push_type: String, // "alert" or "background"
}

struct ApnsPayload {
aps: ApsPayload,
}

struct ApsPayload {
alert: Option<ApnsAlert>,
badge: Option<u32>,
sound: Option<ApnsSound>,
content_available: Option<u8>, // 1 for silent push
mutable_content: Option<u8>, // 1 for notification extension
interruption_level: Option<String>, // "active", "time-sensitive", "passive"
}

Sending Push Notifications

// crates/bf_user/src/fcm.rs

pub async fn send_push(
env: &Env,
device_token: &str,
notification: &Notification,
push_type: PushType,
) -> FcmResult {
// 1. Get Firebase access token from bf_auth
let firebase_token = get_firebase_token(env).await?;

// 2. Build FCM message
let message = build_fcm_message(device_token, notification, push_type);

// 3. Send to FCM
let fcm_url = format!(
"https://fcm.googleapis.com/v1/projects/{}/messages:send",
env.var("FCM_PROJECT_ID")?
);

let response = reqwest::Client::new()
.post(&fcm_url)
.header("Authorization", format!("Bearer {}", firebase_token))
.json(&FcmRequest { message })
.send()
.await?;

// 4. Handle response
match response.status().as_u16() {
200 => {
let result: FcmSuccessResponse = response.json().await?;
FcmResult::Success { message_id: result.name }
}
404 | 410 => FcmResult::InvalidToken, // Token expired/invalid
429 | 500..=599 => FcmResult::RetryableError(response.text().await?),
_ => FcmResult::PermanentError(response.text().await?),
}
}

fn build_fcm_message(
token: &str,
notification: &Notification,
push_type: PushType,
) -> FcmMessage {
let mut data = HashMap::new();
data.insert("notification_id".to_string(), notification.id.clone());
data.insert("notification_type".to_string(), notification.notification_type.as_str().to_string());
if let Some(property_id) = &notification.property_id {
data.insert("property_id".to_string(), property_id.clone());
}
if let Some(action_url) = &notification.action_url {
data.insert("action_url".to_string(), action_url.clone());
}

FcmMessage {
token: token.to_string(),
notification: Some(Notification {
title: notification.title.clone(),
body: notification.body.clone(),
}),
data,
android: build_android_config(push_type),
apns: build_apns_config(push_type, notification),
}
}

Device Token Management

Registration

// Called when user logs in or token refreshes

pub async fn register_device_token(
conn: &DOConnection,
request: RegisterDeviceTokenRequest,
user_email: &str,
) -> UserResult<String> {
let id = Uuid::new_v4().to_string();
let now = now_timestamp();

// Delete old token if refreshing
if let Some(old_token) = &request.old_token {
sqlx_d1::query!(
"DELETE FROM device_tokens WHERE token = ?",
old_token
).execute(conn).await?;
}

// Insert new token
sqlx_d1::query!(
r#"
INSERT INTO device_tokens (id, user_email, token, platform, device_id, created_at, last_used_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(token) DO UPDATE SET last_used_at = ?
"#,
id,
user_email,
request.token,
request.platform,
request.device_id,
now,
now,
now
).execute(conn).await?;

Ok(id)
}

Token Cleanup

// Called when FCM returns InvalidToken

pub async fn delete_invalid_token(conn: &DOConnection, token: &str) -> UserResult<()> {
sqlx_d1::query!(
"DELETE FROM device_tokens WHERE token = ?",
token
).execute(conn).await?;

Ok(())
}

Notification Storage

// Create notification in DO

pub async fn create_notification(
conn: &DOConnection,
request: &CreateNotificationRequest,
) -> UserResult<Notification> {
let id = Uuid::new_v4().to_string();
let now = now_timestamp();

sqlx_d1::query!(
r#"
INSERT INTO notifications (
id, type, title, body, device_id, device_name,
action_url, read, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?)
"#,
id,
request.notification_type.as_str(),
request.title,
request.body,
request.device_id,
request.device_name,
request.action_url,
now
).execute(conn).await?;

Ok(Notification {
id,
notification_type: request.notification_type.clone(),
title: request.title.clone(),
body: request.body.clone(),
device_id: request.device_id.clone(),
device_name: request.device_name.clone(),
action_url: request.action_url.clone(),
read: false,
created_at: now,
})
}

Critical Alert Handling

iOS requires special entitlement for true critical alerts. Currently using time-sensitive as workaround:

fn build_apns_config(push_type: PushType, notification: &Notification) -> ApnsConfig {
match push_type {
PushType::Critical => ApnsConfig {
headers: ApnsHeaders {
apns_priority: "10".to_string(),
apns_push_type: "alert".to_string(),
},
payload: ApnsPayload {
aps: ApsPayload {
alert: Some(ApnsAlert {
title: notification.title.clone(),
body: notification.body.clone(),
}),
badge: Some(1),
// TODO: Change to ApnsSound::Critical once entitlement approved
sound: Some(ApnsSound::Simple("alarm.caf".to_string())),
mutable_content: Some(1),
interruption_level: Some("time-sensitive".to_string()),
content_available: None,
},
},
},
// ... other cases
}
}

See Also