forked from
smokesignal.events/smokesignal
i18n+filtering fork - fluent-templates v2
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, ¤t_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, ¤t_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}