i18n+filtering fork - fluent-templates v2
at main 519 lines 18 kB view raw
1use std::fmt; 2 3use anyhow::Result; 4use axum::{ 5 extract::{Path, Query}, 6 response::{IntoResponse, Redirect}, 7}; 8use axum_htmx::HxBoosted; 9use minijinja::context as template_context; 10use serde::{Deserialize, Serialize}; 11 12use crate::atproto::lexicon::community::lexicon::calendar::event::NSID; 13use crate::atproto::lexicon::events::smokesignal::calendar::event::NSID as SMOKESIGNAL_EVENT_NSID; 14use crate::contextual_error; 15use crate::create_renderer; 16use crate::http::context::UserRequestContext; 17use crate::http::errors::CommonError; 18use crate::http::errors::ViewEventError; 19use crate::http::errors::WebError; 20use crate::http::event_view::hydrate_event_rsvp_counts; 21use crate::http::event_view::EventView; 22use crate::http::pagination::Pagination; 23use crate::http::tab_selector::TabSelector; 24use crate::http::utils::{convert_urls_to_links, url_from_aturi}; 25use crate::resolve::parse_input; 26use crate::resolve::InputType; 27use crate::storage::event::count_event_rsvps; 28use crate::storage::event::event_exists; 29use crate::storage::event::event_get; 30use crate::storage::event::get_event_rsvps; 31use crate::storage::event::get_user_rsvp; 32use crate::storage::handle::handle_for_did; 33use crate::storage::handle::handle_for_handle; 34use crate::storage::handle::model::Handle; 35use crate::storage::StoragePool; 36 37#[derive(Debug, Deserialize, Serialize, PartialEq)] 38pub enum RSVPTab { 39 Going, 40 Interested, 41 NotGoing, 42} 43 44impl fmt::Display for RSVPTab { 45 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 46 match self { 47 RSVPTab::Going => write!(f, "going"), 48 RSVPTab::Interested => write!(f, "interested"), 49 RSVPTab::NotGoing => write!(f, "notgoing"), 50 } 51 } 52} 53 54impl From<TabSelector> for RSVPTab { 55 fn from(tab_selector: TabSelector) -> Self { 56 match tab_selector.tab.clone().unwrap_or_default().as_str() { 57 "interested" => RSVPTab::Interested, 58 "notgoing" => RSVPTab::NotGoing, 59 _ => RSVPTab::Going, 60 } 61 } 62} 63 64#[derive(Debug, Deserialize)] 65pub struct CollectionParam { 66 #[serde(default = "default_collection")] 67 collection: String, 68} 69 70fn default_collection() -> String { 71 NSID.to_string() 72} 73 74/// Helper function to fetch the organizer's handle (which contains their time zone) 75/// This is used to implement the time zone selection logic. 76async fn fetch_organizer_handle(pool: &StoragePool, did: &str) -> Option<Handle> { 77 match handle_for_did(pool, did).await { 78 Ok(handle) => Some(handle), 79 Err(err) => { 80 tracing::warn!("Failed to fetch organizer handle: {}", err); 81 None 82 } 83 } 84} 85 86pub async fn handle_view_event( 87 ctx: UserRequestContext, 88 HxBoosted(hx_boosted): HxBoosted, 89 Path((handle_slug, event_rkey)): Path<(String, String)>, 90 pagination: Query<Pagination>, 91 tab_selector: Query<TabSelector>, 92 collection_param: Query<CollectionParam>, 93) -> Result<impl IntoResponse, WebError> { 94 let _event_url = format!( 95 "https://{}/event/{}/{}", 96 ctx.web_context.config.external_base, handle_slug, event_rkey 97 ); 98 99 // Create the template renderer with enhanced context 100 let language_clone = ctx.language.clone(); 101 let renderer = create_renderer!(ctx.web_context.clone(), language_clone, hx_boosted, false); 102 103 let profile: Result<Handle, WebError> = match parse_input(&handle_slug) { 104 Ok(InputType::Handle(handle)) => handle_for_handle(&ctx.web_context.pool, &handle) 105 .await 106 .map_err(|err| err.into()), 107 Ok(InputType::Plc(did) | InputType::Web(did)) => { 108 handle_for_did(&ctx.web_context.pool, &did) 109 .await 110 .map_err(|err| err.into()) 111 } 112 _ => Err(CommonError::InvalidHandleSlug.into()), 113 }; 114 115 if let Err(err) = profile { 116 return contextual_error!(renderer: renderer, err, template_context!{}); 117 } 118 119 let profile = profile.unwrap(); 120 121 // We'll use TimeZoneSelector to implement the time zone selection logic 122 // The timezone selection will happen after we fetch the event 123 124 // Use the provided collection parameter instead of the default NSID 125 let collection = &collection_param.0.collection; 126 let lookup_aturi = format!("at://{}/{}/{}", profile.did, collection, event_rkey); 127 128 // Check if this is a legacy event (not using the standard community calendar collection) 129 let is_legacy_event = collection != NSID; 130 131 // If this is a legacy event, check if a standard version exists 132 // If this is a standard event, check if a legacy version exists (migrated event) 133 let standard_event_exists; 134 let has_been_migrated; 135 136 if is_legacy_event { 137 // This is a legacy event, check if a standard version exists 138 let standard_aturi = format!("at://{}/{}/{}", profile.did, NSID, event_rkey); 139 140 // Try to fetch the standard event 141 standard_event_exists = match event_get(&ctx.web_context.pool, &standard_aturi).await { 142 Ok(_) => { 143 tracing::info!("Standard version of legacy event found: {}", standard_aturi); 144 true 145 } 146 Err(_) => { 147 tracing::info!("No standard version found for legacy event"); 148 false 149 } 150 }; 151 // Legacy events are never migrated 152 has_been_migrated = false; 153 } else { 154 // This is a standard event, so there's no standard version to check for 155 standard_event_exists = false; 156 157 // Check if this is a migrated event (i.e., a legacy version exists) 158 let legacy_aturi = format!( 159 "at://{}/{}/{}", 160 profile.did, SMOKESIGNAL_EVENT_NSID, event_rkey 161 ); 162 has_been_migrated = match event_get(&ctx.web_context.pool, &legacy_aturi).await { 163 Ok(_) => { 164 tracing::info!( 165 "Legacy version found for standard event - this is a migrated event: {}", 166 legacy_aturi 167 ); 168 true 169 } 170 Err(_) => { 171 tracing::info!("No legacy version found for standard event"); 172 false 173 } 174 }; 175 }; 176 177 // Try to get the event from the requested collection 178 let event_get_result = event_get(&ctx.web_context.pool, &lookup_aturi).await; 179 180 let event_result = match &event_get_result { 181 Ok(event) => { 182 let organizer_handle = { 183 if ctx 184 .current_handle 185 .clone() 186 .is_some_and(|h| h.did == event.did) 187 { 188 ctx.current_handle.clone() 189 } else { 190 fetch_organizer_handle(&ctx.web_context.pool, &event.did).await 191 } 192 }; 193 194 EventView::try_from_with_locale( 195 (ctx.current_handle.as_ref(), 196 organizer_handle.as_ref(), 197 event), 198 Some(&ctx.language.0), 199 ) 200 } 201 Err(err) => Err(ViewEventError::EventNotFound(err.to_string()).into()), 202 }; 203 204 // If event not found and using default collection, try fallback collection 205 if event_result.is_err() && collection == NSID { 206 // Check if event exists in fallback collection 207 let fallback_aturi = format!( 208 "at://{}/{}/{}", 209 profile.did, SMOKESIGNAL_EVENT_NSID, event_rkey 210 ); 211 tracing::info!( 212 "Event not found in default collection, trying fallback: {}", 213 fallback_aturi 214 ); 215 216 // Try to fetch from fallback collection 217 let fallback_result: Result<bool, WebError> = 218 event_exists(&ctx.web_context.pool, &fallback_aturi) 219 .await 220 .map_err(|err| ViewEventError::FallbackFailed(err.to_string()).into()); 221 222 match fallback_result { 223 Ok(true) => { 224 // HTTP 307 temporary redirect 225 let encoded_collection = urlencoding::encode(SMOKESIGNAL_EVENT_NSID).to_string(); 226 let uri = format!( 227 "/{}/{}?collection={}", 228 handle_slug, event_rkey, encoded_collection 229 ); 230 return Ok(Redirect::to(&uri).into_response()); 231 } 232 Err(err) => { 233 tracing::error!(fallback_aturi, err = ?err, "failed to lookup fallback_aturi: {}", err); 234 } 235 _ => {} 236 } 237 } 238 239 if let Err(err) = event_result { 240 return contextual_error!(renderer: renderer, err, template_context!{}); 241 } 242 243 let mut event = event_result.unwrap(); 244 245 // Hydrate event organizer display name 246 let mut event_vec = vec![event]; 247 248 // if let Err(err) = hydrate_events(&ctx.web_context.pool, &mut event_vec).await { 249 // tracing::warn!("Failed to hydrate event organizers: {}", err); 250 // } 251 252 if let Err(err) = hydrate_event_rsvp_counts(&ctx.web_context.pool, &mut event_vec).await { 253 tracing::warn!("Failed to hydrate event counts: {}", err); 254 } 255 256 event = event_vec.remove(0); 257 258 let is_self = ctx 259 .current_handle 260 .clone() 261 .is_some_and(|inner_current_entity| inner_current_entity.did == profile.did); 262 263 let (_page, _page_size) = pagination.clamped(); 264 let tab: RSVPTab = tab_selector.0.into(); 265 let tab_name = tab.to_string(); 266 267 let event_url = url_from_aturi(&ctx.web_context.config.external_base, &event.aturi)?; 268 269 // Add Edit button link if the user is the event creator 270 let can_edit = ctx 271 .current_handle 272 .clone() 273 .is_some_and(|current_entity| current_entity.did == profile.did); 274 275 // Variables for RSVP data 276 let ( 277 user_rsvp_status, 278 going_count, 279 interested_count, 280 notgoing_count, 281 going_handles, 282 interested_handles, 283 notgoing_handles, 284 user_has_standard_rsvp, 285 ) = if !is_legacy_event { 286 // Only fetch RSVP data for standard (non-legacy) events 287 // Get user's RSVP status if logged in 288 let user_rsvp = if let Some(current_entity) = &ctx.current_handle { 289 match get_user_rsvp(&ctx.web_context.pool, &lookup_aturi, &current_entity.did).await { 290 Ok(status) => status, 291 Err(err) => { 292 tracing::error!("Error getting user RSVP status: {:?}", err); 293 None 294 } 295 } 296 } else { 297 None 298 }; 299 300 // Get counts for all RSVP statuses 301 let going_count = count_event_rsvps(&ctx.web_context.pool, &lookup_aturi, "going") 302 .await 303 .unwrap_or_default(); 304 305 let interested_count = 306 count_event_rsvps(&ctx.web_context.pool, &lookup_aturi, "interested") 307 .await 308 .unwrap_or_default(); 309 310 let notgoing_count = count_event_rsvps(&ctx.web_context.pool, &lookup_aturi, "notgoing") 311 .await 312 .unwrap_or_default(); 313 314 // Only get handles for the active tab 315 let (going_handles, interested_handles, notgoing_handles) = match tab { 316 RSVPTab::Going => { 317 let rsvps = get_event_rsvps(&ctx.web_context.pool, &lookup_aturi, Some("going")) 318 .await 319 .unwrap_or_default(); 320 321 let mut handles = Vec::new(); 322 for (did, _) in &rsvps { 323 if let Ok(handle) = handle_for_did(&ctx.web_context.pool, did).await { 324 handles.push(handle.handle); 325 } 326 } 327 (handles, Vec::new(), Vec::new()) 328 } 329 RSVPTab::Interested => { 330 let rsvps = 331 get_event_rsvps(&ctx.web_context.pool, &lookup_aturi, Some("interested")) 332 .await 333 .unwrap_or_default(); 334 335 let mut handles = Vec::new(); 336 for (did, _) in &rsvps { 337 if let Ok(handle) = handle_for_did(&ctx.web_context.pool, did).await { 338 handles.push(handle.handle); 339 } 340 } 341 (Vec::new(), handles, Vec::new()) 342 } 343 RSVPTab::NotGoing => { 344 let rsvps = get_event_rsvps(&ctx.web_context.pool, &lookup_aturi, Some("notgoing")) 345 .await 346 .unwrap_or_default(); 347 348 let mut handles = Vec::new(); 349 for (did, _) in &rsvps { 350 if let Ok(handle) = handle_for_did(&ctx.web_context.pool, did).await { 351 handles.push(handle.handle); 352 } 353 } 354 (Vec::new(), Vec::new(), handles) 355 } 356 }; 357 358 ( 359 user_rsvp, 360 going_count, 361 interested_count, 362 notgoing_count, 363 going_handles, 364 interested_handles, 365 notgoing_handles, 366 false, // Not used for standard events 367 ) 368 } else { 369 // For legacy events, still check if the user has RSVP'd 370 let user_rsvp = if let Some(current_entity) = &ctx.current_handle { 371 match get_user_rsvp(&ctx.web_context.pool, &lookup_aturi, &current_entity.did).await { 372 Ok(status) => status, 373 Err(err) => { 374 tracing::error!("Error getting user RSVP status for legacy event: {:?}", err); 375 None 376 } 377 } 378 } else { 379 None 380 }; 381 382 // If this is a legacy event, check if the user already has an RSVP for the standard version 383 // to avoid showing the migrate button unnecessarily 384 let user_has_standard_rsvp = 385 if standard_event_exists && user_rsvp.is_some() && ctx.current_handle.is_some() { 386 // Construct the standard event URI 387 let standard_event_uri = format!("at://{}/{}/{}", profile.did, NSID, event_rkey); 388 389 // Check if the user has an RSVP for the standard event 390 match get_user_rsvp( 391 &ctx.web_context.pool, 392 &standard_event_uri, 393 &ctx.current_handle.as_ref().unwrap().did, 394 ) 395 .await 396 { 397 Ok(Some(_)) => { 398 tracing::info!( 399 "User already has an RSVP for the standard event: {}", 400 standard_event_uri 401 ); 402 true 403 } 404 Ok(None) => false, 405 Err(err) => { 406 tracing::error!( 407 "Error checking if user has RSVP for standard event: {:?}", 408 err 409 ); 410 false // Default to false to allow migration attempt if we can't determine 411 } 412 } 413 } else { 414 false 415 }; 416 417 tracing::info!("Legacy event detected, only fetching user RSVP status"); 418 ( 419 user_rsvp, 420 0, 421 0, 422 0, 423 Vec::new(), 424 Vec::new(), 425 Vec::new(), 426 user_has_standard_rsvp, 427 ) 428 }; 429 430 // Set counts on event 431 let mut event_with_counts = event; 432 event_with_counts.count_going = going_count; 433 event_with_counts.count_interested = interested_count; 434 event_with_counts.count_notgoing = notgoing_count; 435 436 // Convert URLs to clickable links in event descriptions 437 if let Some(ref mut description) = event_with_counts.description { 438 *description = convert_urls_to_links(description); 439 } 440 if let Some(ref mut description_short) = event_with_counts.description_short { 441 *description_short = convert_urls_to_links(description_short); 442 } 443 444 Ok(renderer.render_template( 445 "view_event", 446 template_context! { 447 event => event_with_counts, 448 is_self, 449 can_edit, 450 going => going_handles, 451 interested => interested_handles, 452 notgoing => notgoing_handles, 453 active_tab => tab_name, 454 user_rsvp_status, 455 handle_slug, 456 event_rkey, 457 collection => collection.clone(), 458 is_legacy_event, 459 standard_event_exists, 460 has_been_migrated, 461 user_has_standard_rsvp, 462 standard_event_url => if standard_event_exists { 463 Some(format!("/{}/{}", handle_slug, event_rkey)) 464 } else { 465 None 466 }, 467 SMOKESIGNAL_EVENT_NSID => SMOKESIGNAL_EVENT_NSID, 468 using_SMOKESIGNAL_EVENT_NSID => collection == SMOKESIGNAL_EVENT_NSID, 469 }, 470 ctx.current_handle.as_ref(), 471 &event_url, 472 )) 473} 474 475#[cfg(test)] 476mod tests { 477 use super::*; 478 // No imports needed for basic unit tests 479 480 // Simple unit test for the RSVPTab conversion 481 #[test] 482 fn test_rsrvp_tab_from_tab_selector() { 483 let tab_selector = TabSelector { 484 tab: Some("going".to_string()), 485 }; 486 let rsvp_tab = RSVPTab::from(tab_selector); 487 assert_eq!(rsvp_tab, RSVPTab::Going); 488 489 let tab_selector = TabSelector { 490 tab: Some("interested".to_string()), 491 }; 492 let rsvp_tab = RSVPTab::from(tab_selector); 493 assert_eq!(rsvp_tab, RSVPTab::Interested); 494 495 let tab_selector = TabSelector { 496 tab: Some("notgoing".to_string()), 497 }; 498 let rsvp_tab = RSVPTab::from(tab_selector); 499 assert_eq!(rsvp_tab, RSVPTab::NotGoing); 500 501 // Default case 502 let tab_selector = TabSelector { tab: None }; 503 let rsvp_tab = RSVPTab::from(tab_selector); 504 assert_eq!(rsvp_tab, RSVPTab::Going); 505 } 506 507 #[test] 508 fn test_rsvp_tab_display() { 509 assert_eq!(RSVPTab::Going.to_string(), "going"); 510 assert_eq!(RSVPTab::Interested.to_string(), "interested"); 511 assert_eq!(RSVPTab::NotGoing.to_string(), "notgoing"); 512 } 513 514 // Test collection parameter default 515 #[test] 516 fn test_collection_param_default() { 517 assert_eq!(default_collection(), NSID); 518 } 519}