The smokesignal.events web application
56
fork

Configure Feed

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

at main 477 lines 21 kB view raw
1use anyhow::Result; 2use atproto_record::aturi::ATURI; 3use axum::{extract::State, response::IntoResponse}; 4use axum_extra::extract::{Cached, Form}; 5use axum_htmx::{HxBoosted, HxRequest}; 6use axum_template::RenderHtml; 7use chrono::Utc; 8use http::Method; 9use metrohash::MetroHash64; 10use minijinja::context as template_context; 11use serde_json; 12use std::hash::Hasher; 13use std::str::FromStr; 14 15use crate::atproto::auth::create_dpop_auth_from_session; 16use crate::{ 17 contextual_error, 18 http::{ 19 context::WebContext, 20 errors::{CommonError, CreateRsvpError, WebError}, 21 middleware_auth::Auth, 22 middleware_i18n::Language, 23 rsvp_form::{BuildRSVPForm, BuildRsvpContentState}, 24 utils::url_from_aturi, 25 }, 26 select_template, 27 storage::event::{RsvpInsertParams, rsvp_get_by_event_and_did, rsvp_insert_with_metadata}, 28}; 29use atproto_client::com::atproto::repo::{PutRecordRequest, PutRecordResponse, put_record}; 30use atproto_record::lexicon::community::lexicon::calendar::rsvp::{NSID, Rsvp, RsvpStatus}; 31 32pub(crate) async fn handle_create_rsvp( 33 method: Method, 34 State(web_context): State<WebContext>, 35 Language(language): Language, 36 Cached(auth): Cached<Auth>, 37 HxRequest(hx_request): HxRequest, 38 HxBoosted(hx_boosted): HxBoosted, 39 Form(mut build_rsvp_form): Form<BuildRSVPForm>, 40) -> Result<impl IntoResponse, WebError> { 41 let current_handle = auth.require("/rsvp")?; 42 let session = auth.session().ok_or(CommonError::NotAuthorized)?; 43 44 let default_context = template_context! { 45 current_handle, 46 language => language.to_string(), 47 canonical_url => format!("https://{}/rsvp", web_context.config.external_base), 48 hx_request, 49 hx_boosted, 50 }; 51 52 let render_template = select_template!("create_rsvp", hx_boosted, hx_request, language); 53 let error_template = select_template!(hx_boosted, hx_request, language); 54 55 if build_rsvp_form.build_state.is_none() { 56 build_rsvp_form.build_state = Some(BuildRsvpContentState::default()); 57 } 58 59 if method == Method::GET { 60 #[cfg(debug_assertions)] 61 { 62 build_rsvp_form.status = Some("going".to_string()); 63 } 64 65 build_rsvp_form.build_state = Some(BuildRsvpContentState::Selecting); 66 67 return Ok(RenderHtml( 68 &render_template, 69 web_context.engine.clone(), 70 template_context! { ..default_context, ..template_context! { 71 build_rsvp_form, 72 }}, 73 ) 74 .into_response()); 75 } 76 77 match build_rsvp_form.build_state { 78 Some(BuildRsvpContentState::Reset) => { 79 build_rsvp_form.build_state = Some(BuildRsvpContentState::Selecting); 80 build_rsvp_form.subject_aturi = None; 81 build_rsvp_form.subject_cid = None; 82 build_rsvp_form.status = Some("going".to_string()); 83 } 84 Some(BuildRsvpContentState::Selecting) => {} 85 Some(BuildRsvpContentState::Selected) => { 86 build_rsvp_form 87 .hydrate( 88 &web_context.pool, 89 &web_context.i18n_context.locales, 90 &language, 91 ) 92 .await; 93 94 let found_errors = 95 build_rsvp_form.validate(&web_context.i18n_context.locales, &language); 96 if found_errors { 97 build_rsvp_form.build_state = Some(BuildRsvpContentState::Selecting); 98 } else { 99 build_rsvp_form.build_state = Some(BuildRsvpContentState::Selected); 100 } 101 } 102 Some(BuildRsvpContentState::Review) => { 103 build_rsvp_form 104 .hydrate( 105 &web_context.pool, 106 &web_context.i18n_context.locales, 107 &language, 108 ) 109 .await; 110 111 let found_errors = 112 build_rsvp_form.validate(&web_context.i18n_context.locales, &language); 113 114 if !found_errors { 115 // Check if the event requires confirmed email 116 let event_aturi = build_rsvp_form.subject_aturi.as_ref().unwrap(); 117 let event = 118 crate::storage::event::event_get(&web_context.pool, event_aturi).await?; 119 120 if event.require_confirmed_email { 121 // Check if user has confirmed email 122 let has_confirmed_email = 123 crate::storage::notification::notification_get_confirmed_email( 124 &web_context.pool, 125 &current_handle.did, 126 ) 127 .await? 128 .is_some(); 129 130 if !has_confirmed_email { 131 return contextual_error!( 132 web_context, 133 language, 134 error_template, 135 default_context, 136 CreateRsvpError::EmailConfirmationRequired 137 ); 138 } 139 } 140 141 let now = Utc::now(); 142 143 // Create DPoP auth from session 144 let dpop_auth = create_dpop_auth_from_session(session)?; 145 146 let subject = atproto_record::lexicon::com::atproto::repo::StrongRef { 147 uri: build_rsvp_form.subject_aturi.as_ref().unwrap().to_string(), 148 cid: build_rsvp_form.subject_cid.as_ref().unwrap().to_string(), 149 }; 150 151 let status = match build_rsvp_form.status.as_ref().unwrap().as_str() { 152 "going" => RsvpStatus::Going, 153 "interested" => RsvpStatus::Interested, 154 "notgoing" => RsvpStatus::NotGoing, 155 _ => unreachable!(), 156 }; 157 158 // Check if this is a "going" RSVP for email notifications later 159 let is_going_rsvp = status == RsvpStatus::Going; 160 161 let mut h = MetroHash64::default(); 162 h.write(subject.uri.clone().as_bytes()); 163 164 let record_key = crockford::encode(h.finish()); 165 166 // Fetch existing RSVP from database to check if status is changing 167 let existing_rsvp = rsvp_get_by_event_and_did( 168 &web_context.pool, 169 build_rsvp_form.subject_aturi.as_ref().unwrap(), 170 &current_handle.did, 171 ) 172 .await 173 .ok() 174 .flatten(); 175 176 // Determine the timestamp, signatures to use, and whether to clear validated_at 177 let (created_at_timestamp, signatures_to_use, status_changed) = 178 if let Some(ref existing) = existing_rsvp { 179 // Parse existing RSVP record to get current status and signatures 180 let existing_rsvp_record: Result<Rsvp, _> = 181 serde_json::from_value(existing.record.0.clone()); 182 183 if let Ok(existing_record) = existing_rsvp_record { 184 // Check if status is changing 185 let status_is_changing = existing_record.status != status; 186 187 if status_is_changing { 188 // Status changed - clear signatures, keep existing created_at, and mark status as changed 189 (existing_record.created_at, vec![], true) 190 } else { 191 // Status unchanged - preserve signatures, created_at, and mark status as unchanged 192 ( 193 existing_record.created_at, 194 existing_record.signatures, 195 false, 196 ) 197 } 198 } else { 199 // Could not parse existing record - use current time, empty signatures, treat as new 200 (now, vec![], false) 201 } 202 } else { 203 // No existing RSVP - use current time, empty signatures, treat as new (not a change) 204 (now, vec![], false) 205 }; 206 207 let the_record = Rsvp { 208 created_at: created_at_timestamp, 209 subject, 210 status, 211 signatures: signatures_to_use, 212 extra: Default::default(), 213 }; 214 215 let rsvp_record = PutRecordRequest { 216 repo: current_handle.did.clone(), 217 collection: NSID.to_string(), 218 validate: false, 219 record_key, 220 record: the_record.clone(), 221 swap_commit: None, 222 swap_record: None, 223 }; 224 225 let put_record_result = put_record( 226 &web_context.http_client, 227 &atproto_client::client::Auth::DPoP(dpop_auth), 228 &current_handle.pds, 229 rsvp_record, 230 ) 231 .await; 232 233 let create_record_result = match put_record_result { 234 Ok(PutRecordResponse::StrongRef { uri, cid, .. }) => { 235 atproto_record::lexicon::com::atproto::repo::StrongRef { uri, cid } 236 } 237 Ok(PutRecordResponse::Error(err)) => { 238 return contextual_error!( 239 web_context, 240 language, 241 error_template, 242 default_context, 243 CreateRsvpError::ServerError { 244 message: err.error_message() 245 } 246 ); 247 } 248 Err(err) => { 249 return contextual_error!( 250 web_context, 251 language, 252 error_template, 253 default_context, 254 err 255 ); 256 } 257 }; 258 259 let rsvp_insert_result = rsvp_insert_with_metadata( 260 &web_context.pool, 261 RsvpInsertParams { 262 aturi: &create_record_result.uri, 263 cid: &create_record_result.cid, 264 did: &current_handle.did, 265 lexicon: NSID, 266 record: &the_record, 267 event_aturi: build_rsvp_form.subject_aturi.as_ref().unwrap(), 268 event_cid: build_rsvp_form.subject_cid.as_ref().unwrap(), 269 status: build_rsvp_form.status.as_ref().unwrap(), 270 clear_validated_at: status_changed, 271 }, 272 ) 273 .await; 274 275 if let Err(err) = rsvp_insert_result { 276 return contextual_error!( 277 web_context, 278 language, 279 error_template, 280 default_context, 281 err 282 ); 283 } 284 285 // Send email notification if RSVP is "going" and emailer is enabled 286 if is_going_rsvp && let Some(ref emailer) = web_context.emailer { 287 // Extract event creator DID from event aturi 288 if let Ok(event_aturi_parsed) = 289 ATURI::from_str(build_rsvp_form.subject_aturi.as_ref().unwrap()) 290 { 291 let event_creator_did = event_aturi_parsed.authority; 292 293 // Get event URL (used by both notifications) 294 let event_url = url_from_aturi( 295 &web_context.config.external_base, 296 build_rsvp_form.subject_aturi.as_ref().unwrap(), 297 ) 298 .unwrap_or_else(|_| { 299 format!("https://{}/event", web_context.config.external_base) 300 }); 301 302 // Get full event details from database (used by both notifications) 303 let event_result = crate::storage::event::event_get( 304 &web_context.pool, 305 build_rsvp_form.subject_aturi.as_ref().unwrap(), 306 ) 307 .await; 308 309 // Send notification to event creator 310 if let Ok(event_creator_profile) = 311 crate::storage::identity_profile::handle_for_did( 312 &web_context.pool, 313 &event_creator_did, 314 ) 315 .await 316 && let Some(creator_email) = &event_creator_profile.email 317 && !creator_email.is_empty() 318 { 319 let event_name = if let Ok(ref event) = event_result { 320 if event.name.is_empty() { 321 "Untitled Event" 322 } else { 323 &event.name 324 } 325 } else { 326 "Untitled Event" 327 }; 328 329 if let Err(err) = emailer 330 .notify_rsvp_going( 331 creator_email, 332 &event_creator_did, 333 &current_handle.did, 334 &current_handle.handle, 335 event_name, 336 &event_url, 337 ) 338 .await 339 { 340 tracing::warn!( 341 ?err, 342 "Failed to send RSVP notification email to event creator" 343 ); 344 } 345 } 346 347 // Send event summary to RSVPer (current user) 348 if let Ok(event) = event_result { 349 // Check if user has a confirmed email 350 if let Ok(Some(rsvper_email)) = 351 crate::storage::notification::notification_get_confirmed_email( 352 &web_context.pool, 353 &current_handle.did, 354 ) 355 .await 356 { 357 // Extract event details from the record 358 let event_details = 359 crate::storage::event::extract_event_details(&event); 360 361 // Only send if event has start time 362 if let Some(starts_at) = event_details.starts_at { 363 // Extract location string from locations array 364 let location_opt = event_details 365 .locations 366 .iter() 367 .filter_map(|loc| { 368 if let atproto_record::lexicon::community::lexicon::location::LocationOrRef::InlineAddress(typed_address) = loc { 369 Some(crate::storage::event::format_address(&typed_address.inner)) 370 } else { 371 None 372 } 373 }) 374 .next(); // Take the first address found 375 376 // Generate ICS file 377 let ics_content = crate::ics_helpers::generate_event_ics( 378 &event.aturi, 379 &event_details.name, 380 if event_details.description.is_empty() { 381 None 382 } else { 383 Some(event_details.description.as_ref()) 384 }, 385 location_opt.as_deref(), 386 starts_at, 387 event_details.ends_at, 388 Some(&event_url), 389 ); 390 391 if let Ok(ics_string) = ics_content { 392 let ics_bytes = ics_string.into_bytes(); 393 394 // Format times for email display 395 let event_start_time = starts_at 396 .format("%B %e, %Y at %l:%M %p %Z") 397 .to_string(); 398 let event_end_time = event_details.ends_at.map(|t| { 399 t.format("%B %e, %Y at %l:%M %p %Z").to_string() 400 }); 401 402 // Send event summary email with ICS attachment 403 if let Err(err) = emailer 404 .send_event_summary( 405 &rsvper_email, 406 &current_handle.did, 407 &event_details.name, 408 if event_details.description.is_empty() { 409 None 410 } else { 411 Some(event_details.description.as_ref()) 412 }, 413 location_opt.as_deref(), 414 &event_start_time, 415 event_end_time.as_deref(), 416 &event_url, 417 ics_bytes, 418 ) 419 .await 420 { 421 tracing::warn!( 422 ?err, 423 "Failed to send event summary email to RSVPer" 424 ); 425 } 426 } else { 427 tracing::warn!( 428 "Failed to generate ICS file for event summary" 429 ); 430 } 431 } 432 } 433 } 434 } 435 } 436 437 let event_url = url_from_aturi( 438 &web_context.config.external_base, 439 build_rsvp_form.subject_aturi.clone().unwrap().as_str(), 440 )?; 441 442 // Pass redirect URL only for "going" RSVPs 443 let redirect_url = if is_going_rsvp { 444 event.rsvp_redirect_url.clone() 445 } else { 446 None 447 }; 448 449 // Get event AT-URI for share button 450 let event_aturi = build_rsvp_form.subject_aturi.clone(); 451 452 return Ok(RenderHtml( 453 &render_template, 454 web_context.engine.clone(), 455 template_context! { ..default_context, ..template_context! { 456 build_rsvp_form, 457 event_url, 458 redirect_url, 459 is_going_rsvp, 460 event_aturi, 461 }}, 462 ) 463 .into_response()); 464 } 465 } 466 None => unreachable!(), 467 } 468 469 Ok(RenderHtml( 470 &render_template, 471 web_context.engine.clone(), 472 template_context! { ..default_context, ..template_context! { 473 build_rsvp_form 474 }}, 475 ) 476 .into_response()) 477}