use axum::{Json, extract::State}; use serde::{Deserialize, Serialize}; use serde_json::json; use tracing::info; use tranquil_db_traits::{CommsChannel, CommsStatus, CommsType}; use tranquil_pds::api::error::{ApiError, DbResultExt}; use tranquil_pds::auth::{Active, Auth}; use tranquil_pds::state::AppState; use tranquil_types::{Did, Handle}; #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct NotificationPrefsOutput { pub preferred_channel: CommsChannel, pub email: String, pub discord_username: Option, pub discord_verified: bool, pub telegram_username: Option, pub telegram_verified: bool, pub signal_username: Option, pub signal_verified: bool, } pub async fn get_notification_prefs( State(state): State, auth: Auth, ) -> Result, ApiError> { let prefs = state .repos .user .get_notification_prefs(&auth.did) .await .log_db_err("get notification prefs")? .ok_or(ApiError::AccountNotFound)?; Ok(Json(NotificationPrefsOutput { preferred_channel: prefs.preferred_channel, email: prefs.email, discord_username: prefs.discord_username, discord_verified: prefs.discord_verified, telegram_username: prefs.telegram_username, telegram_verified: prefs.telegram_verified, signal_username: prefs.signal_username, signal_verified: prefs.signal_verified, })) } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct NotificationHistoryEntry { pub created_at: String, pub channel: CommsChannel, pub comms_type: CommsType, pub status: CommsStatus, pub subject: Option, pub body: String, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct GetNotificationHistoryOutput { pub notifications: Vec, } pub async fn get_notification_history( State(state): State, auth: Auth, ) -> Result, ApiError> { let user_id = state .repos .user .get_id_by_did(&auth.did) .await .log_db_err("get user id by did")? .ok_or(ApiError::AccountNotFound)?; let rows = state .repos .infra .get_notification_history(user_id, 50) .await .log_db_err("get notification history")?; let sensitive_types = [ CommsType::EmailVerification, CommsType::PasswordReset, CommsType::EmailUpdate, CommsType::TwoFactorCode, CommsType::PasskeyRecovery, CommsType::MigrationVerification, CommsType::PlcOperation, CommsType::ChannelVerification, ]; let notifications = rows .iter() .map(|row| { let body = if sensitive_types.contains(&row.comms_type) { "[Code redacted for security]".to_string() } else { row.body.clone() }; NotificationHistoryEntry { created_at: row.created_at.to_rfc3339(), channel: row.channel, comms_type: row.comms_type, status: row.status, subject: row.subject.clone(), body, } }) .collect(); Ok(Json(GetNotificationHistoryOutput { notifications })) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct UpdateNotificationPrefsInput { pub preferred_channel: Option, pub email: Option, pub discord_username: Option, pub telegram_username: Option, pub signal_username: Option, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct UpdateNotificationPrefsOutput { pub success: bool, #[serde(skip_serializing_if = "Vec::is_empty")] pub verification_required: Vec, } pub async fn request_channel_verification( state: &AppState, user_id: uuid::Uuid, did: &Did, channel: CommsChannel, identifier: &str, handle: Option<&Handle>, ) -> Result { let token = tranquil_pds::auth::verification_token::generate_channel_update_token( did, channel, identifier, ); let formatted_token = tranquil_pds::auth::verification_token::format_token_for_display(&token); match channel { CommsChannel::Email => { let hostname = &tranquil_config::get().server.hostname; let fallback_handle = Handle::from("user".to_string()); tranquil_pds::comms::comms_repo::enqueue_email_update( state.repos.infra.as_ref(), user_id, identifier, handle.unwrap_or(&fallback_handle), &formatted_token, hostname, ) .await .log_db_err("enqueue email verification")?; } _ => { let hostname = &tranquil_config::get().server.hostname; let encoded_token = urlencoding::encode(&formatted_token); let encoded_identifier = urlencoding::encode(identifier); let verify_link = format!( "https://{}/app/verify?token={}&identifier={}", hostname, encoded_token, encoded_identifier ); let prefs = state .repos .user .get_comms_prefs(user_id) .await .ok() .flatten(); let locale = prefs .as_ref() .and_then(|p| p.preferred_locale.as_deref()) .unwrap_or("en"); let strings = tranquil_pds::comms::get_strings(locale); let body = tranquil_pds::comms::format_message( strings.channel_verification_body, &[("code", &formatted_token), ("verify_link", &verify_link)], ); let subject = tranquil_pds::comms::format_message( strings.channel_verification_subject, &[("hostname", hostname)], ); let recipient = match channel { CommsChannel::Telegram => state .repos .user .get_telegram_chat_id(user_id) .await .ok() .flatten() .map(|id| id.to_string()) .unwrap_or_else(|| identifier.to_string()), _ => identifier.to_string(), }; state .repos .infra .enqueue_comms( Some(user_id), channel, tranquil_db_traits::CommsType::ChannelVerification, &recipient, Some(&subject), &body, Some(json!({"code": formatted_token})), ) .await .log_db_err("enqueue channel verification")?; } } Ok(token) } async fn process_messaging_channel_update( state: &AppState, user_id: uuid::Uuid, did: &Did, channel: CommsChannel, raw_value: &str, effective_channel: CommsChannel, verification_required: &mut Vec, ) -> Result<(), ApiError> { let clean = match channel { CommsChannel::Discord => raw_value.trim().to_lowercase(), CommsChannel::Telegram => raw_value.trim_start_matches('@').to_string(), CommsChannel::Signal => raw_value.trim().trim_start_matches('@').to_lowercase(), CommsChannel::Email => raw_value.trim().to_lowercase(), }; if clean.is_empty() { if effective_channel == channel { return Err(ApiError::InvalidRequest(format!( "Cannot remove {:?} while it is the preferred notification channel", channel ))); } match channel { CommsChannel::Discord => state .repos .user .clear_discord(user_id) .await .log_db_err("clear discord")?, CommsChannel::Telegram => state .repos .user .clear_telegram(user_id) .await .log_db_err("clear telegram")?, CommsChannel::Signal => state .repos .user .clear_signal(user_id) .await .log_db_err("clear signal")?, CommsChannel::Email => {} }; info!(did = %did, channel = ?channel, "Cleared channel"); return Ok(()); } let valid = match channel { CommsChannel::Discord => tranquil_pds::api::validation::is_valid_discord_username(&clean), CommsChannel::Telegram => tranquil_pds::api::validation::is_valid_telegram_username(&clean), CommsChannel::Signal => tranquil_pds::comms::is_valid_signal_username(&clean), CommsChannel::Email => tranquil_pds::api::validation::is_valid_email(&clean), }; if !valid { return Err(match channel { CommsChannel::Discord => ApiError::InvalidRequest( "Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)".into(), ), CommsChannel::Telegram => ApiError::InvalidRequest( "Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore".into(), ), CommsChannel::Signal => ApiError::InvalidRequest( "Invalid Signal username. Must be a 3-32 character nickname, a dot, then a 2-20 digit discriminator".into(), ), CommsChannel::Email => ApiError::InvalidEmail, }); } match channel { CommsChannel::Discord => state .repos .user .set_unverified_discord(user_id, &clean) .await .log_db_err("set unverified discord")?, CommsChannel::Telegram => state .repos .user .set_unverified_telegram(user_id, &clean) .await .log_db_err("set unverified telegram")?, CommsChannel::Signal => state .repos .user .set_unverified_signal(user_id, &clean) .await .log_db_err("set unverified signal")?, CommsChannel::Email => {} }; if matches!(channel, CommsChannel::Signal) { request_channel_verification(state, user_id, did, channel, &clean, None).await?; } verification_required.push(channel); info!(did = %did, channel = ?channel, value = %clean, "Stored unverified channel username"); Ok(()) } pub async fn update_notification_prefs( State(state): State, auth: Auth, Json(input): Json, ) -> Result, ApiError> { let user_row = state .repos .user .get_id_handle_email_by_did(&auth.did) .await .log_db_err("get user by did")? .ok_or(ApiError::AccountNotFound)?; let user_id = user_row.id; let handle = user_row.handle; let current_email = user_row.email; let current_prefs = state .repos .user .get_notification_prefs(&auth.did) .await .log_db_err("get notification prefs for update")? .ok_or(ApiError::AccountNotFound)?; let effective_channel = input .preferred_channel .as_deref() .map(|ch| { ch.parse::().map_err(|_| { ApiError::InvalidRequest( "Invalid channel. Must be one of: email, discord, telegram, signal".into(), ) }) }) .transpose()? .unwrap_or(current_prefs.preferred_channel); let mut verification_required: Vec = Vec::new(); if input.preferred_channel.is_some() { state .repos .user .update_preferred_comms_channel(&auth.did, effective_channel) .await .log_db_err("update preferred channel")?; info!(did = %auth.did, channel = ?effective_channel, "Updated preferred notification channel"); } if let Some(ref new_email) = input.email { let email_clean = new_email.trim().to_lowercase(); if email_clean.is_empty() { return Err(ApiError::InvalidRequest("Email cannot be empty".into())); } if !tranquil_pds::api::validation::is_valid_email(&email_clean) { return Err(ApiError::InvalidEmail); } if current_email.as_ref().map(|e| e.to_lowercase()) != Some(email_clean.clone()) { request_channel_verification( &state, user_id, &auth.did, CommsChannel::Email, &email_clean, Some(&handle), ) .await?; verification_required.push(CommsChannel::Email); info!(did = %auth.did, "Requested email verification"); } } if let Some(ref discord_username) = input.discord_username { process_messaging_channel_update( &state, user_id, &auth.did, CommsChannel::Discord, discord_username, effective_channel, &mut verification_required, ) .await?; } if let Some(ref telegram) = input.telegram_username { process_messaging_channel_update( &state, user_id, &auth.did, CommsChannel::Telegram, telegram, effective_channel, &mut verification_required, ) .await?; } if let Some(ref signal) = input.signal_username { process_messaging_channel_update( &state, user_id, &auth.did, CommsChannel::Signal, signal, effective_channel, &mut verification_required, ) .await?; } Ok(Json(UpdateNotificationPrefsOutput { success: true, verification_required, })) }