The smokesignal.events web application
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 ¤t_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 ¤t_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 ¤t_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: ¤t_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 ¤t_handle.did,
334 ¤t_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 ¤t_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 ¤t_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}