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 processingcrates/bf_user/src/fcm.rs- FCM integrationcrates/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) = ¬ification.property_id {
data.insert("property_id".to_string(), property_id.clone());
}
if let Some(action_url) = ¬ification.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
- realtime-events.md - Event processing
- webhook-handling.md - Event sources
- mobile-architecture.md - Mobile push handling