forked from
smokesignal.events/smokesignal
i18n+filtering fork - fluent-templates v2
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(¤t_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: ¤t_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(¤t_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(¤t_handle),
663 &canonical_url,
664 ))
665}