i18n+filtering fork - fluent-templates v2
at main 665 lines 28 kB view raw
1use anyhow::Result; 2use axum::{extract::Path, response::IntoResponse}; 3use axum_extra::extract::Form; 4use axum_htmx::{HxBoosted, HxRequest}; 5use chrono::Utc; 6use http::Method; 7use minijinja::context as template_context; 8 9use crate::{ 10 atproto::{ 11 auth::SimpleOAuthSessionProvider, 12 client::{OAuthPdsClient, PutRecordRequest}, 13 lexicon::community::lexicon::calendar::event::{ 14 Event as LexiconCommunityEvent, EventLink, EventLocation, Mode, NamedUri, Status, 15 NSID as LexiconCommunityEventNSID, 16 }, 17 lexicon::community::lexicon::location::Address, 18 }, 19 contextual_error, 20 http::context::UserRequestContext, 21 http::errors::EditEventError, 22 http::errors::{CommonError, WebError}, 23 http::event_form::BuildLocationForm, 24 http::event_form::{BuildEventContentState, BuildEventForm, BuildLinkForm, BuildStartsForm}, 25 http::location_edit_status::{check_location_edit_status, LocationEditStatus}, 26 http::timezones::supported_timezones, 27 http::utils::url_from_aturi, 28 resolve::{parse_input, InputType}, 29 create_renderer, 30 storage::{ 31 event::{event_get, event_update_with_metadata}, 32 handle::{handle_for_did, handle_for_handle}, 33 }, 34}; 35 36pub async fn handle_edit_event( 37 ctx: UserRequestContext, 38 method: Method, 39 HxBoosted(hx_boosted): HxBoosted, 40 HxRequest(hx_request): HxRequest, 41 Path((handle_slug, event_rkey)): Path<(String, String)>, 42 Form(mut build_event_form): Form<BuildEventForm>, 43) -> Result<impl IntoResponse, WebError> { 44 let current_handle = ctx 45 .auth 46 .require(&ctx.web_context.config.destination_key, "/")?; 47 48 let canonical_url = format!( 49 "https://{}/{}/{}/edit", 50 ctx.web_context.config.external_base, handle_slug, event_rkey 51 ); 52 53 // Create the template renderer with enhanced context 54 let renderer = create_renderer!(ctx.web_context.clone(), ctx.language.clone(), hx_boosted, hx_request); 55 56 // Lookup the event 57 let profile = match parse_input(&handle_slug) { 58 Ok(InputType::Handle(handle)) => handle_for_handle(&ctx.web_context.pool, &handle) 59 .await 60 .map_err(WebError::from), 61 Ok(InputType::Plc(did) | InputType::Web(did)) => { 62 handle_for_did(&ctx.web_context.pool, &did) 63 .await 64 .map_err(WebError::from) 65 } 66 _ => Err(WebError::from(EditEventError::InvalidHandleSlug)), 67 }?; 68 69 let lookup_aturi = format!( 70 "at://{}/{}/{}", 71 profile.did, LexiconCommunityEventNSID, event_rkey 72 ); 73 74 // Check if the user is authorized to edit this event (must be the creator) 75 if profile.did != current_handle.did { 76 return contextual_error!( 77 renderer: renderer, 78 EditEventError::NotAuthorized, 79 template_context!{} 80 ); 81 } 82 83 let event = event_get(&ctx.web_context.pool, &lookup_aturi).await; 84 if let Err(err) = event { 85 return contextual_error!(renderer: renderer, err, template_context!{}); 86 } 87 88 let event = event.unwrap(); 89 90 // Check if this is a community calendar event (we only support editing those) 91 if event.lexicon != LexiconCommunityEventNSID { 92 return contextual_error!( 93 renderer: renderer, 94 EditEventError::UnsupportedEventType, 95 template_context!{} 96 ); 97 } 98 99 // Try to parse the event data 100 let community_event = 101 match serde_json::from_value::<LexiconCommunityEvent>(event.record.0.clone()) { 102 Ok(event) => event, 103 Err(_) => { 104 return contextual_error!( 105 renderer: renderer, 106 CommonError::InvalidEventFormat, 107 template_context!{} 108 ); 109 } 110 }; 111 112 let (default_tz, timezones) = supported_timezones(ctx.current_handle.as_ref()); 113 114 let parsed_tz = default_tz 115 .parse::<chrono_tz::Tz>() 116 .unwrap_or(chrono_tz::UTC); 117 118 if build_event_form.build_state.is_none() { 119 build_event_form.build_state = Some(BuildEventContentState::default()); 120 } 121 122 let mut starts_form = BuildStartsForm::from(build_event_form.clone()); 123 if starts_form.build_state.is_none() { 124 starts_form.build_state = Some(BuildEventContentState::default()); 125 } 126 127 if starts_form.tz.is_none() { 128 starts_form.tz = Some(default_tz.to_string()); 129 } 130 131 let mut location_form = BuildLocationForm::from(build_event_form.clone()); 132 if location_form.build_state.is_none() { 133 location_form.build_state = Some(BuildEventContentState::default()); 134 } 135 136 let mut link_form = BuildLinkForm::from(build_event_form.clone()); 137 if link_form.build_state.is_none() { 138 link_form.build_state = Some(BuildEventContentState::default()); 139 } 140 141 let is_development = cfg!(debug_assertions); 142 143 // Check if event locations can be edited 144 let location_edit_status = match &community_event { 145 LexiconCommunityEvent::Current { locations, .. } => check_location_edit_status(locations), 146 }; 147 148 // Set flags for template rendering 149 let locations_editable = location_edit_status.is_editable(); 150 let location_edit_reason = location_edit_status.edit_reason(); 151 152 // For GET requests, populate the form with existing event data 153 if method == Method::GET { 154 // Extract data from the parsed community event 155 match &community_event { 156 LexiconCommunityEvent::Current { 157 name, 158 description, 159 status, 160 mode, 161 starts_at, 162 ends_at, 163 uris, 164 .. 165 } => { 166 build_event_form.name = Some(name.clone()); 167 build_event_form.description = Some(description.clone()); 168 169 // If we have a single address location, populate the form fields with its data 170 if let LocationEditStatus::Editable(Address::Current { 171 country, 172 postal_code, 173 region, 174 locality, 175 street, 176 name, 177 }) = &location_edit_status 178 { 179 build_event_form.location_country = Some(country.clone()); 180 build_event_form.location_postal_code = postal_code.clone(); 181 build_event_form.location_region = region.clone(); 182 build_event_form.location_locality = locality.clone(); 183 build_event_form.location_street = street.clone(); 184 build_event_form.location_name = name.clone(); 185 186 location_form.location_country = Some(country.clone()); 187 location_form.location_postal_code = postal_code.clone(); 188 location_form.location_region = region.clone(); 189 location_form.location_locality = locality.clone(); 190 location_form.location_street = street.clone(); 191 location_form.location_name = name.clone(); 192 } 193 194 // If we have URIs, populate the link form with the first one 195 if !uris.is_empty() { 196 let EventLink::Current { uri, name } = &uris[0]; 197 build_event_form.link_value = Some(uri.clone()); 198 build_event_form.link_name = name.clone(); 199 200 link_form.link_value = Some(uri.clone()); 201 link_form.link_name = name.clone(); 202 } 203 204 // Convert status enum to string 205 if let Some(status_val) = status { 206 build_event_form.status = Some( 207 match status_val { 208 Status::Planned => "planned", 209 Status::Scheduled => "scheduled", 210 Status::Cancelled => "cancelled", 211 Status::Postponed => "postponed", 212 Status::Rescheduled => "rescheduled", 213 } 214 .to_string(), 215 ); 216 } 217 218 // Convert mode enum to string 219 if let Some(mode_val) = mode { 220 build_event_form.mode = Some( 221 match mode_val { 222 Mode::InPerson => "inperson", 223 Mode::Virtual => "virtual", 224 Mode::Hybrid => "hybrid", 225 } 226 .to_string(), 227 ); 228 } 229 230 // Set date/time fields 231 if let Some(start_time) = starts_at { 232 let local_dt = start_time.with_timezone(&parsed_tz); 233 234 starts_form.starts_date = Some(local_dt.format("%Y-%m-%d").to_string()); 235 starts_form.starts_time = Some(local_dt.format("%H:%M").to_string()); 236 starts_form.starts_at = Some(start_time.to_string()); 237 starts_form.starts_display = 238 Some(local_dt.format("%A, %B %-d, %Y %r %Z").to_string()); 239 240 build_event_form.starts_at = starts_form.starts_at.clone(); 241 } else { 242 starts_form.starts_display = Some("--".to_string()); 243 } 244 245 if let Some(end_time) = ends_at { 246 let local_dt = end_time.with_timezone(&parsed_tz); 247 248 starts_form.include_ends = Some(true); 249 starts_form.ends_date = Some(local_dt.format("%Y-%m-%d").to_string()); 250 starts_form.ends_time = Some(local_dt.format("%H:%M").to_string()); 251 starts_form.ends_at = Some(end_time.to_string()); 252 starts_form.ends_display = 253 Some(local_dt.format("%A, %B %-d, %Y %r %Z").to_string()); 254 255 build_event_form.ends_at = starts_form.ends_at.clone(); 256 } else { 257 starts_form.ends_display = Some("--".to_string()); 258 } 259 } 260 } 261 262 build_event_form.build_state = Some(BuildEventContentState::Selected); 263 starts_form.build_state = Some(BuildEventContentState::Selected); 264 location_form.build_state = Some(BuildEventContentState::Selected); 265 link_form.build_state = Some(BuildEventContentState::Selected); 266 267 // Extract location information for template display 268 let location_display_info = match &community_event { 269 LexiconCommunityEvent::Current { locations, .. } => { 270 if locations.is_empty() { 271 None 272 } else { 273 // Format locations for display 274 let mut formatted_locations = Vec::new(); 275 276 for loc in locations { 277 match loc { 278 EventLocation::Address(Address::Current { 279 country, 280 postal_code, 281 region, 282 locality, 283 street, 284 name, 285 }) => { 286 let mut data = serde_json::Map::new(); 287 data.insert( 288 "type".to_string(), 289 serde_json::Value::String("address".to_string()), 290 ); 291 data.insert( 292 "country".to_string(), 293 serde_json::Value::String(country.clone()), 294 ); 295 296 if let Some(n) = name { 297 data.insert( 298 "name".to_string(), 299 serde_json::Value::String(n.clone()), 300 ); 301 } 302 if let Some(s) = street { 303 data.insert( 304 "street".to_string(), 305 serde_json::Value::String(s.clone()), 306 ); 307 } 308 if let Some(l) = locality { 309 data.insert( 310 "locality".to_string(), 311 serde_json::Value::String(l.clone()), 312 ); 313 } 314 if let Some(r) = region { 315 data.insert( 316 "region".to_string(), 317 serde_json::Value::String(r.clone()), 318 ); 319 } 320 if let Some(pc) = postal_code { 321 data.insert( 322 "postal_code".to_string(), 323 serde_json::Value::String(pc.clone()), 324 ); 325 } 326 327 formatted_locations.push(serde_json::Value::Object(data)); 328 } 329 EventLocation::Uri(NamedUri::Current { uri, name }) => { 330 let mut data = serde_json::Map::new(); 331 data.insert( 332 "type".to_string(), 333 serde_json::Value::String("uri".to_string()), 334 ); 335 data.insert( 336 "uri".to_string(), 337 serde_json::Value::String(uri.clone()), 338 ); 339 340 if let Some(n) = name { 341 data.insert( 342 "name".to_string(), 343 serde_json::Value::String(n.clone()), 344 ); 345 } 346 347 formatted_locations.push(serde_json::Value::Object(data)); 348 } 349 _ => { 350 let mut data = serde_json::Map::new(); 351 data.insert( 352 "type".to_string(), 353 serde_json::Value::String("unknown".to_string()), 354 ); 355 formatted_locations.push(serde_json::Value::Object(data)); 356 } 357 } 358 } 359 360 Some(formatted_locations) 361 } 362 } 363 }; 364 365 return Ok(renderer.render_template( 366 "edit_event", 367 template_context! { 368 build_event_form, 369 starts_form, 370 location_form, 371 link_form, 372 event_rkey, 373 handle_slug, 374 timezones, 375 is_development, 376 locations_editable, 377 location_edit_reason, 378 location_display_info, 379 create_event => false, 380 submit_url => format!("/{}/{}/edit", handle_slug, event_rkey), 381 cancel_url => format!("/{}/{}", handle_slug, event_rkey), 382 }, 383 Some(&current_handle), 384 &canonical_url, 385 )); 386 } 387 388 // Process form state changes just like in create_event 389 match build_event_form.build_state { 390 Some(BuildEventContentState::Reset) => { 391 build_event_form.build_state = Some(BuildEventContentState::Selecting); 392 build_event_form.name = None; 393 build_event_form.name_error = None; 394 build_event_form.description = None; 395 build_event_form.description_error = None; 396 build_event_form.status = None; 397 build_event_form.status_error = None; 398 build_event_form.starts_at = None; 399 build_event_form.starts_at_error = None; 400 build_event_form.ends_at = None; 401 build_event_form.ends_at_error = None; 402 build_event_form.mode = None; 403 build_event_form.mode_error = None; 404 405 // Regenerate starts_form from the updated build_event_form to ensure date/time fields are synced 406 starts_form = BuildStartsForm::from(build_event_form.clone()); 407 starts_form.build_state = Some(BuildEventContentState::Selecting); 408 409 location_form = BuildLocationForm::from(build_event_form.clone()); 410 location_form.build_state = Some(BuildEventContentState::Selecting); 411 412 link_form = BuildLinkForm::from(build_event_form.clone()); 413 link_form.build_state = Some(BuildEventContentState::Selecting); 414 } 415 Some(BuildEventContentState::Selected) => { 416 let found_errors = 417 build_event_form.validate(&ctx.web_context.i18n_context.locales, &ctx.language); 418 if found_errors { 419 build_event_form.build_state = Some(BuildEventContentState::Selecting); 420 } else { 421 build_event_form.build_state = Some(BuildEventContentState::Selected); 422 } 423 424 // TODO: Consider adding the event CID and rkey to the form and 425 // comparing it before submission. If the event CID is different 426 // than what is contained in the form, then it could help prevent 427 // race conditions where the event is double edited. 428 429 // Preserving "extra" fields from the original record to ensure 430 // we don't lose any additional metadata during edits 431 432 if !found_errors { 433 // Compose an updated event record 434 435 let starts_at = build_event_form 436 .starts_at 437 .as_ref() 438 .and_then(|v| v.parse::<chrono::DateTime<Utc>>().ok()); 439 let ends_at = build_event_form 440 .ends_at 441 .as_ref() 442 .and_then(|v| v.parse::<chrono::DateTime<Utc>>().ok()); 443 444 let mode = build_event_form 445 .mode 446 .as_ref() 447 .and_then(|v| match v.as_str() { 448 "inperson" => Some(Mode::InPerson), 449 "virtual" => Some(Mode::Virtual), 450 "hybrid" => Some(Mode::Hybrid), 451 _ => None, 452 }); 453 454 let status = build_event_form 455 .status 456 .as_ref() 457 .and_then(|v| match v.as_str() { 458 "planned" => Some(Status::Planned), 459 "scheduled" => Some(Status::Scheduled), 460 "cancelled" => Some(Status::Cancelled), 461 "postponed" => Some(Status::Postponed), 462 "rescheduled" => Some(Status::Rescheduled), 463 _ => None, 464 }); 465 466 let client_auth: SimpleOAuthSessionProvider = 467 SimpleOAuthSessionProvider::try_from(ctx.auth.1.unwrap())?; 468 469 let client = OAuthPdsClient { 470 http_client: &ctx.web_context.http_client, 471 pds: &current_handle.pds, 472 }; 473 474 // Extract existing locations and URIs from the original record 475 let (locations, uris) = match &community_event { 476 LexiconCommunityEvent::Current { 477 locations, uris, .. 478 } => { 479 // Check if locations are editable 480 let location_edit_status = check_location_edit_status(locations); 481 482 // If locations aren't editable but the form has location data, return an error 483 if !location_edit_status.is_editable() 484 && (build_event_form.location_country.is_some() 485 || build_event_form.location_street.is_some() 486 || build_event_form.location_locality.is_some() 487 || build_event_form.location_region.is_some() 488 || build_event_form.location_postal_code.is_some() 489 || build_event_form.location_name.is_some()) 490 { 491 // Return appropriate error based on edit status 492 // Note: NoLocations case removed since it's now handled as Editable 493 let error = match location_edit_status { 494 LocationEditStatus::MultipleLocations => { 495 EditEventError::MultipleLocationsPresent 496 } 497 LocationEditStatus::UnsupportedLocationType => { 498 EditEventError::UnsupportedLocationType 499 } 500 _ => unreachable!(), 501 }; 502 503 return contextual_error!( 504 renderer: renderer, 505 error, 506 template_context!{} 507 ); 508 } 509 510 // Handle locations 511 let updated_locations = if location_edit_status.is_editable() 512 && build_event_form.location_country.is_some() 513 { 514 // Create a new Address from form data 515 let address = Address::Current { 516 country: build_event_form.location_country.clone().unwrap(), 517 postal_code: build_event_form.location_postal_code.clone(), 518 region: build_event_form.location_region.clone(), 519 locality: build_event_form.location_locality.clone(), 520 street: build_event_form.location_street.clone(), 521 name: build_event_form.location_name.clone(), 522 }; 523 524 vec![EventLocation::Address(address)] 525 } else { 526 // Preserve existing locations 527 locations.clone() 528 }; 529 530 // Handle links 531 let updated_uris = if build_event_form.link_value.is_some() { 532 let uri = build_event_form.link_value.clone().unwrap(); 533 let name = build_event_form.link_name.clone(); 534 vec![EventLink::Current { uri, name }] 535 } else { 536 uris.clone() 537 }; 538 539 (updated_locations, updated_uris) 540 } 541 }; 542 543 // Extract existing extra fields from the original record 544 let extra = match &community_event { 545 LexiconCommunityEvent::Current { extra, .. } => extra.clone(), 546 }; 547 548 let updated_record = LexiconCommunityEvent::Current { 549 name: build_event_form 550 .name 551 .clone() 552 .ok_or(CommonError::FieldRequired)?, 553 description: build_event_form 554 .description 555 .clone() 556 .ok_or(CommonError::FieldRequired)?, 557 created_at: match &community_event { 558 LexiconCommunityEvent::Current { created_at, .. } => *created_at, 559 }, 560 starts_at, 561 ends_at, 562 mode, 563 status, 564 locations, 565 uris, 566 extra, // Use the preserved extra fields 567 }; 568 569 // Update the record in ATP 570 let update_record_request = PutRecordRequest { 571 repo: current_handle.did.clone(), 572 collection: LexiconCommunityEventNSID.to_string(), 573 record_key: event_rkey.clone(), 574 record: updated_record.clone(), 575 validate: false, 576 swap_commit: None, 577 swap_record: Some(event.cid.clone()), 578 }; 579 580 let update_record_result = 581 client.put_record(&client_auth, update_record_request).await; 582 583 if let Err(err) = update_record_result { 584 return contextual_error!( 585 renderer: renderer, 586 err, 587 template_context!{} 588 ); 589 } 590 591 let update_record_result = update_record_result.unwrap(); 592 593 let name = match &updated_record { 594 LexiconCommunityEvent::Current { name, .. } => name, 595 }; 596 597 // Update the local record 598 let event_update_result = event_update_with_metadata( 599 &ctx.web_context.pool, 600 &lookup_aturi, 601 &update_record_result.cid, 602 &updated_record, 603 name, 604 ) 605 .await; 606 607 if let Err(err) = event_update_result { 608 return contextual_error!( 609 renderer: renderer, 610 err, 611 template_context!{} 612 ); 613 } 614 615 let event_url = 616 url_from_aturi(&ctx.web_context.config.external_base, &lookup_aturi)?; 617 618 return Ok(renderer.render_template( 619 "edit_event", 620 template_context! { 621 build_event_form, 622 starts_form, 623 location_form, 624 link_form, 625 operation_completed => true, 626 event_url, 627 event_rkey, 628 handle_slug, 629 timezones, 630 is_development, 631 locations_editable, 632 location_edit_reason, 633 create_event => false, 634 submit_url => format!("/{}/{}/edit", handle_slug, event_rkey), 635 cancel_url => format!("/{}/{}", handle_slug, event_rkey), 636 }, 637 Some(&current_handle), 638 &canonical_url, 639 )); 640 } 641 } 642 _ => {} 643 } 644 645 Ok(renderer.render_template( 646 "edit_event", 647 template_context! { 648 build_event_form, 649 starts_form, 650 location_form, 651 link_form, 652 event_rkey, 653 handle_slug, 654 timezones, 655 is_development, 656 locations_editable, 657 location_edit_reason, 658 create_event => false, 659 submit_url => format!("/{}/{}/edit", handle_slug, event_rkey), 660 cancel_url => format!("/{}/{}", handle_slug, event_rkey), 661 }, 662 Some(&current_handle), 663 &canonical_url, 664 )) 665}