The smokesignal.events web application
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 333 lines 12 kB view raw
1use anyhow::Result; 2use axum::{extract::Path, response::IntoResponse}; 3use axum_extra::extract::Form; 4use axum_template::RenderHtml; 5use http::StatusCode; 6use minijinja::context as template_context; 7use serde::Deserialize; 8 9use crate::{ 10 contextual_error, 11 http::cache_countries::{other_countries, popular_countries}, 12 http::context::UserRequestContext, 13 http::errors::{CommonError, WebError}, 14 select_template, 15 storage::{ 16 event::{count_event_rsvps, event_get, get_event_rsvps_for_export}, 17 identity_profile::{handle_for_did, handle_for_handle}, 18 }, 19}; 20use atproto_record::lexicon::community::lexicon::calendar::event::NSID as LexiconCommunityEventNSID; 21 22#[derive(Debug, Deserialize)] 23pub struct PrivateContentForm { 24 private_content: Option<String>, 25 private_content_criteria_going_confirmed: Option<bool>, 26 private_content_criteria_going: Option<bool>, 27 private_content_criteria_interested: Option<bool>, 28 send_notifications: Option<bool>, 29} 30 31pub(crate) async fn handle_manage_event_content_save( 32 ctx: UserRequestContext, 33 Path((handle_slug, event_rkey)): Path<(String, String)>, 34 Form(form): Form<PrivateContentForm>, 35) -> Result<impl IntoResponse, WebError> { 36 let current_handle = ctx 37 .auth 38 .require(&format!("/{}/{}/manage/content", handle_slug, event_rkey))?; 39 40 let default_context = template_context! { 41 current_handle, 42 language => ctx.language.to_string(), 43 }; 44 45 let error_template = select_template!(false, false, ctx.language); 46 47 // Lookup the event 48 let profile = if handle_slug.starts_with("did:") { 49 handle_for_did(&ctx.web_context.pool, &handle_slug) 50 .await 51 .map_err(WebError::from) 52 } else { 53 let handle = if let Some(handle) = handle_slug.strip_prefix('@') { 54 handle 55 } else { 56 &handle_slug 57 }; 58 handle_for_handle(&ctx.web_context.pool, handle) 59 .await 60 .map_err(WebError::from) 61 }?; 62 63 let lookup_aturi = format!( 64 "at://{}/{}/{}", 65 profile.did, LexiconCommunityEventNSID, event_rkey 66 ); 67 68 // Check if the user is authorized to manage this event (must be the creator) 69 if profile.did != current_handle.did { 70 return contextual_error!( 71 ctx.web_context, 72 ctx.language, 73 error_template, 74 default_context, 75 CommonError::NotAuthorized, 76 StatusCode::FORBIDDEN 77 ); 78 } 79 80 let event = event_get(&ctx.web_context.pool, &lookup_aturi).await; 81 if let Err(err) = event { 82 return contextual_error!( 83 ctx.web_context, 84 ctx.language, 85 error_template, 86 default_context, 87 err, 88 StatusCode::NOT_FOUND 89 ); 90 } 91 92 let event = event.unwrap(); 93 94 // Build display criteria array from checkboxes 95 let mut display_criteria = Vec::new(); 96 if form 97 .private_content_criteria_going_confirmed 98 .unwrap_or(false) 99 { 100 display_criteria.push("going_confirmed".to_string()); 101 } 102 if form.private_content_criteria_going.unwrap_or(false) { 103 display_criteria.push("going".to_string()); 104 } 105 if form.private_content_criteria_interested.unwrap_or(false) { 106 display_criteria.push("interested".to_string()); 107 } 108 109 let private_content = form.private_content.as_deref().unwrap_or(""); 110 let has_content = !private_content.is_empty() || !display_criteria.is_empty(); 111 112 // Only save if there's content or criteria, otherwise delete 113 if has_content { 114 if let Err(err) = crate::storage::private_event_content::private_event_content_upsert( 115 &ctx.web_context.pool, 116 &lookup_aturi, 117 &display_criteria, 118 private_content, 119 ) 120 .await 121 { 122 tracing::error!("Failed to save private event content: {:?}", err); 123 return contextual_error!( 124 ctx.web_context, 125 ctx.language, 126 error_template, 127 default_context, 128 err, 129 StatusCode::INTERNAL_SERVER_ERROR 130 ); 131 } 132 } else { 133 // Delete private content if both content and criteria are empty 134 if let Err(err) = crate::storage::private_event_content::private_event_content_delete( 135 &ctx.web_context.pool, 136 &lookup_aturi, 137 ) 138 .await 139 { 140 tracing::error!("Failed to delete private event content: {:?}", err); 141 } 142 } 143 144 // Send notifications if requested and there's content with criteria 145 if form.send_notifications.unwrap_or(false) && has_content && !display_criteria.is_empty() { 146 // Only send notifications if emailer is available 147 if let Some(ref emailer) = ctx.web_context.emailer { 148 // Get all RSVPs with validation status 149 match crate::storage::event::get_event_rsvps_with_validation( 150 &ctx.web_context.pool, 151 &lookup_aturi, 152 None, // Get all statuses 153 ) 154 .await 155 { 156 Ok(rsvps) => { 157 let total_rsvps = rsvps.len(); 158 159 // Filter RSVPs to only those who meet the display criteria 160 let eligible_rsvps: Vec<_> = rsvps 161 .into_iter() 162 .filter(|(_, status, validated_at)| { 163 // Check if this RSVP meets any of the display criteria 164 display_criteria 165 .iter() 166 .any(|criterion| match criterion.as_str() { 167 "going_confirmed" => { 168 status == "going" && validated_at.is_some() 169 } 170 "going" => status == "going", 171 "interested" => status == "interested", 172 _ => false, 173 }) 174 }) 175 .collect(); 176 177 tracing::info!( 178 event_aturi = %lookup_aturi, 179 total_rsvps, 180 eligible_rsvps = eligible_rsvps.len(), 181 "Sending private content notifications to eligible RSVPs" 182 ); 183 184 // Get event URL for the notification 185 let event_url = format!( 186 "https://{}/{}/{}", 187 ctx.web_context.config.external_base, handle_slug, event_rkey 188 ); 189 190 // Send notification to each eligible RSVP 191 for (rsvp_did, _, _) in eligible_rsvps { 192 // Get confirmed email for this DID 193 match crate::storage::notification::notification_get_confirmed_email( 194 &ctx.web_context.pool, 195 &rsvp_did, 196 ) 197 .await 198 { 199 Ok(Some(email)) => { 200 // Send the notification (errors are logged but don't fail the request) 201 if let Err(err) = emailer 202 .notify_event_changed( 203 &email, 204 &rsvp_did, 205 &current_handle.did, 206 &event.name, 207 &event_url, 208 ) 209 .await 210 { 211 tracing::warn!( 212 ?err, 213 rsvp_did = %rsvp_did, 214 "Failed to send private content notification" 215 ); 216 } 217 } 218 Ok(None) => { 219 tracing::debug!( 220 rsvp_did = %rsvp_did, 221 "No confirmed email for RSVP, skipping notification" 222 ); 223 } 224 Err(err) => { 225 tracing::warn!( 226 ?err, 227 rsvp_did = %rsvp_did, 228 "Failed to get email for RSVP" 229 ); 230 } 231 } 232 } 233 } 234 Err(err) => { 235 tracing::warn!( 236 ?err, 237 event_aturi = %lookup_aturi, 238 "Failed to get RSVPs for private content notifications" 239 ); 240 } 241 } 242 } else { 243 tracing::debug!("Emailer not configured, skipping private content notifications"); 244 } 245 } 246 247 // Fetch data needed for rendering 248 let going_count = count_event_rsvps(&ctx.web_context.pool, &lookup_aturi, "going") 249 .await 250 .unwrap_or_default(); 251 let interested_count = count_event_rsvps(&ctx.web_context.pool, &lookup_aturi, "interested") 252 .await 253 .unwrap_or_default(); 254 let notgoing_count = count_event_rsvps(&ctx.web_context.pool, &lookup_aturi, "notgoing") 255 .await 256 .unwrap_or_default(); 257 258 #[derive(serde::Serialize)] 259 struct RsvpCounts { 260 going: u32, 261 interested: u32, 262 notgoing: u32, 263 } 264 265 let rsvp_counts = RsvpCounts { 266 going: going_count, 267 interested: interested_count, 268 notgoing: notgoing_count, 269 }; 270 271 let rsvps = get_event_rsvps_for_export(&ctx.web_context.pool, &lookup_aturi) 272 .await 273 .unwrap_or_default(); 274 275 let delete_event_url = format!( 276 "https://{}/{}/{}/delete", 277 ctx.web_context.config.external_base, handle_slug, event_rkey 278 ); 279 280 let is_development = cfg!(debug_assertions); 281 282 // Prepare private content form data for the template 283 let private_content_form = crate::http::event_form::PrivateContentFormData { 284 private_content: form.private_content.clone(), 285 private_content_criteria_going_confirmed: Some( 286 form.private_content_criteria_going_confirmed 287 .unwrap_or(false), 288 ), 289 private_content_criteria_going: Some(form.private_content_criteria_going.unwrap_or(false)), 290 private_content_criteria_interested: Some( 291 form.private_content_criteria_interested.unwrap_or(false), 292 ), 293 }; 294 295 let submit_url = format!("/{}/{}/edit", handle_slug, event_rkey); 296 let cancel_url = format!("/{}/{}/manage", handle_slug, event_rkey); 297 298 // Get country lists for location form dropdown 299 let popular = popular_countries().unwrap_or_default(); 300 let others = other_countries().unwrap_or_default(); 301 302 // Render tabs + content together with success message 303 Ok(( 304 StatusCode::OK, 305 RenderHtml( 306 "en-us/manage_event_tabs_and_content.partial.html", 307 ctx.web_context.engine.clone(), 308 template_context! { 309 current_handle => ctx.current_handle, 310 language => ctx.language.to_string(), 311 handle_slug, 312 event_rkey, 313 event, 314 rsvps, 315 rsvp_counts, 316 private_content_form, 317 popular_countries => popular, 318 other_countries => others, 319 locations_editable => true, 320 location_edit_reason => None::<String>, 321 delete_event_url, 322 is_development, 323 active_tab => "content", 324 tab_template => "en-us/manage_event_content_tab.html", 325 submit_url, 326 cancel_url, 327 create_event => false, 328 content_saved => true, 329 }, 330 ), 331 ) 332 .into_response()) 333}