forked from
smokesignal.events/smokesignal
The smokesignal.events web application
1use anyhow::Result;
2use axum::{extract::Path, response::IntoResponse};
3use axum_extra::extract::Form;
4use axum_template::RenderHtml;
5use http::StatusCode;
6use minijinja::context as template_context;
7use serde::Deserialize;
8
9use crate::{
10 contextual_error,
11 http::cache_countries::{other_countries, popular_countries},
12 http::context::UserRequestContext,
13 http::errors::{CommonError, WebError},
14 select_template,
15 storage::{
16 event::{count_event_rsvps, event_get, get_event_rsvps_for_export},
17 identity_profile::{handle_for_did, handle_for_handle},
18 },
19};
20use atproto_record::lexicon::community::lexicon::calendar::event::NSID as LexiconCommunityEventNSID;
21
22#[derive(Debug, Deserialize)]
23pub struct PrivateContentForm {
24 private_content: Option<String>,
25 private_content_criteria_going_confirmed: Option<bool>,
26 private_content_criteria_going: Option<bool>,
27 private_content_criteria_interested: Option<bool>,
28 send_notifications: Option<bool>,
29}
30
31pub(crate) async fn handle_manage_event_content_save(
32 ctx: UserRequestContext,
33 Path((handle_slug, event_rkey)): Path<(String, String)>,
34 Form(form): Form<PrivateContentForm>,
35) -> Result<impl IntoResponse, WebError> {
36 let current_handle = ctx
37 .auth
38 .require(&format!("/{}/{}/manage/content", handle_slug, event_rkey))?;
39
40 let default_context = template_context! {
41 current_handle,
42 language => ctx.language.to_string(),
43 };
44
45 let error_template = select_template!(false, false, ctx.language);
46
47 // Lookup the event
48 let profile = if handle_slug.starts_with("did:") {
49 handle_for_did(&ctx.web_context.pool, &handle_slug)
50 .await
51 .map_err(WebError::from)
52 } else {
53 let handle = if let Some(handle) = handle_slug.strip_prefix('@') {
54 handle
55 } else {
56 &handle_slug
57 };
58 handle_for_handle(&ctx.web_context.pool, handle)
59 .await
60 .map_err(WebError::from)
61 }?;
62
63 let lookup_aturi = format!(
64 "at://{}/{}/{}",
65 profile.did, LexiconCommunityEventNSID, event_rkey
66 );
67
68 // Check if the user is authorized to manage this event (must be the creator)
69 if profile.did != current_handle.did {
70 return contextual_error!(
71 ctx.web_context,
72 ctx.language,
73 error_template,
74 default_context,
75 CommonError::NotAuthorized,
76 StatusCode::FORBIDDEN
77 );
78 }
79
80 let event = event_get(&ctx.web_context.pool, &lookup_aturi).await;
81 if let Err(err) = event {
82 return contextual_error!(
83 ctx.web_context,
84 ctx.language,
85 error_template,
86 default_context,
87 err,
88 StatusCode::NOT_FOUND
89 );
90 }
91
92 let event = event.unwrap();
93
94 // Build display criteria array from checkboxes
95 let mut display_criteria = Vec::new();
96 if form
97 .private_content_criteria_going_confirmed
98 .unwrap_or(false)
99 {
100 display_criteria.push("going_confirmed".to_string());
101 }
102 if form.private_content_criteria_going.unwrap_or(false) {
103 display_criteria.push("going".to_string());
104 }
105 if form.private_content_criteria_interested.unwrap_or(false) {
106 display_criteria.push("interested".to_string());
107 }
108
109 let private_content = form.private_content.as_deref().unwrap_or("");
110 let has_content = !private_content.is_empty() || !display_criteria.is_empty();
111
112 // Only save if there's content or criteria, otherwise delete
113 if has_content {
114 if let Err(err) = crate::storage::private_event_content::private_event_content_upsert(
115 &ctx.web_context.pool,
116 &lookup_aturi,
117 &display_criteria,
118 private_content,
119 )
120 .await
121 {
122 tracing::error!("Failed to save private event content: {:?}", err);
123 return contextual_error!(
124 ctx.web_context,
125 ctx.language,
126 error_template,
127 default_context,
128 err,
129 StatusCode::INTERNAL_SERVER_ERROR
130 );
131 }
132 } else {
133 // Delete private content if both content and criteria are empty
134 if let Err(err) = crate::storage::private_event_content::private_event_content_delete(
135 &ctx.web_context.pool,
136 &lookup_aturi,
137 )
138 .await
139 {
140 tracing::error!("Failed to delete private event content: {:?}", err);
141 }
142 }
143
144 // Send notifications if requested and there's content with criteria
145 if form.send_notifications.unwrap_or(false) && has_content && !display_criteria.is_empty() {
146 // Only send notifications if emailer is available
147 if let Some(ref emailer) = ctx.web_context.emailer {
148 // Get all RSVPs with validation status
149 match crate::storage::event::get_event_rsvps_with_validation(
150 &ctx.web_context.pool,
151 &lookup_aturi,
152 None, // Get all statuses
153 )
154 .await
155 {
156 Ok(rsvps) => {
157 let total_rsvps = rsvps.len();
158
159 // Filter RSVPs to only those who meet the display criteria
160 let eligible_rsvps: Vec<_> = rsvps
161 .into_iter()
162 .filter(|(_, status, validated_at)| {
163 // Check if this RSVP meets any of the display criteria
164 display_criteria
165 .iter()
166 .any(|criterion| match criterion.as_str() {
167 "going_confirmed" => {
168 status == "going" && validated_at.is_some()
169 }
170 "going" => status == "going",
171 "interested" => status == "interested",
172 _ => false,
173 })
174 })
175 .collect();
176
177 tracing::info!(
178 event_aturi = %lookup_aturi,
179 total_rsvps,
180 eligible_rsvps = eligible_rsvps.len(),
181 "Sending private content notifications to eligible RSVPs"
182 );
183
184 // Get event URL for the notification
185 let event_url = format!(
186 "https://{}/{}/{}",
187 ctx.web_context.config.external_base, handle_slug, event_rkey
188 );
189
190 // Send notification to each eligible RSVP
191 for (rsvp_did, _, _) in eligible_rsvps {
192 // Get confirmed email for this DID
193 match crate::storage::notification::notification_get_confirmed_email(
194 &ctx.web_context.pool,
195 &rsvp_did,
196 )
197 .await
198 {
199 Ok(Some(email)) => {
200 // Send the notification (errors are logged but don't fail the request)
201 if let Err(err) = emailer
202 .notify_event_changed(
203 &email,
204 &rsvp_did,
205 ¤t_handle.did,
206 &event.name,
207 &event_url,
208 )
209 .await
210 {
211 tracing::warn!(
212 ?err,
213 rsvp_did = %rsvp_did,
214 "Failed to send private content notification"
215 );
216 }
217 }
218 Ok(None) => {
219 tracing::debug!(
220 rsvp_did = %rsvp_did,
221 "No confirmed email for RSVP, skipping notification"
222 );
223 }
224 Err(err) => {
225 tracing::warn!(
226 ?err,
227 rsvp_did = %rsvp_did,
228 "Failed to get email for RSVP"
229 );
230 }
231 }
232 }
233 }
234 Err(err) => {
235 tracing::warn!(
236 ?err,
237 event_aturi = %lookup_aturi,
238 "Failed to get RSVPs for private content notifications"
239 );
240 }
241 }
242 } else {
243 tracing::debug!("Emailer not configured, skipping private content notifications");
244 }
245 }
246
247 // Fetch data needed for rendering
248 let going_count = count_event_rsvps(&ctx.web_context.pool, &lookup_aturi, "going")
249 .await
250 .unwrap_or_default();
251 let interested_count = count_event_rsvps(&ctx.web_context.pool, &lookup_aturi, "interested")
252 .await
253 .unwrap_or_default();
254 let notgoing_count = count_event_rsvps(&ctx.web_context.pool, &lookup_aturi, "notgoing")
255 .await
256 .unwrap_or_default();
257
258 #[derive(serde::Serialize)]
259 struct RsvpCounts {
260 going: u32,
261 interested: u32,
262 notgoing: u32,
263 }
264
265 let rsvp_counts = RsvpCounts {
266 going: going_count,
267 interested: interested_count,
268 notgoing: notgoing_count,
269 };
270
271 let rsvps = get_event_rsvps_for_export(&ctx.web_context.pool, &lookup_aturi)
272 .await
273 .unwrap_or_default();
274
275 let delete_event_url = format!(
276 "https://{}/{}/{}/delete",
277 ctx.web_context.config.external_base, handle_slug, event_rkey
278 );
279
280 let is_development = cfg!(debug_assertions);
281
282 // Prepare private content form data for the template
283 let private_content_form = crate::http::event_form::PrivateContentFormData {
284 private_content: form.private_content.clone(),
285 private_content_criteria_going_confirmed: Some(
286 form.private_content_criteria_going_confirmed
287 .unwrap_or(false),
288 ),
289 private_content_criteria_going: Some(form.private_content_criteria_going.unwrap_or(false)),
290 private_content_criteria_interested: Some(
291 form.private_content_criteria_interested.unwrap_or(false),
292 ),
293 };
294
295 let submit_url = format!("/{}/{}/edit", handle_slug, event_rkey);
296 let cancel_url = format!("/{}/{}/manage", handle_slug, event_rkey);
297
298 // Get country lists for location form dropdown
299 let popular = popular_countries().unwrap_or_default();
300 let others = other_countries().unwrap_or_default();
301
302 // Render tabs + content together with success message
303 Ok((
304 StatusCode::OK,
305 RenderHtml(
306 "en-us/manage_event_tabs_and_content.partial.html",
307 ctx.web_context.engine.clone(),
308 template_context! {
309 current_handle => ctx.current_handle,
310 language => ctx.language.to_string(),
311 handle_slug,
312 event_rkey,
313 event,
314 rsvps,
315 rsvp_counts,
316 private_content_form,
317 popular_countries => popular,
318 other_countries => others,
319 locations_editable => true,
320 location_edit_reason => None::<String>,
321 delete_event_url,
322 is_development,
323 active_tab => "content",
324 tab_template => "en-us/manage_event_content_tab.html",
325 submit_url,
326 cancel_url,
327 create_event => false,
328 content_saved => true,
329 },
330 ),
331 )
332 .into_response())
333}