The smokesignal.events web application

feature: Refactor RSVP form to support sharing email with event organizer

Signed-off-by: Nick Gerakines <nick.gerakines@gmail.com>

+173 -136
+13 -1
.vscode/settings.json
··· 1 { 2 "files.associations": { 3 "*.html": "jinja-html" 4 - } 5 }
··· 1 { 2 "files.associations": { 3 "*.html": "jinja-html" 4 + }, 5 + "sqltools.connections": [ 6 + { 7 + "previewLimit": 50, 8 + "server": "localhost", 9 + "port": 5436, 10 + "driver": "PostgreSQL", 11 + "name": "postgres", 12 + "database": "smokesignal_dev", 13 + "username": "smokesignal", 14 + "password": "smokesignal_dev_password" 15 + } 16 + ] 17 }
+2
migrations/20250706120433_add_email_shared_to_rsvps.sql
···
··· 1 + -- Add email_shared column to rsvps table 2 + ALTER TABLE rsvps ADD COLUMN email_shared BOOLEAN NOT NULL DEFAULT FALSE;
+2
src/http/handle_admin_import_rsvp.rs
··· 236 event_aturi: &event_aturi, 237 event_cid: &event_cid, 238 status, 239 }, 240 ) 241 .await ··· 283 event_aturi: &event_aturi, 284 event_cid: &event_cid, 285 status, 286 }, 287 ) 288 .await
··· 236 event_aturi: &event_aturi, 237 event_cid: &event_cid, 238 status, 239 + email_shared: false, 240 }, 241 ) 242 .await ··· 284 event_aturi: &event_aturi, 285 event_cid: &event_cid, 286 status, 287 + email_shared: false, 288 }, 289 ) 290 .await
+19 -7
src/http/handle_create_rsvp.rs
··· 28 utils::url_from_aturi, 29 }, 30 select_template, 31 - storage::event::rsvp_insert, 32 }; 33 use atproto_client::com::atproto::repo::{put_record, PutRecordRequest, PutRecordResponse}; 34 ··· 43 ) -> Result<impl IntoResponse, WebError> { 44 let current_handle = auth.require(&web_context.config, "/rsvp")?; 45 46 let default_context = template_context! { 47 current_handle, 48 language => language.to_string(), 49 canonical_url => format!("https://{}/rsvp", web_context.config.external_base), 50 hx_request, ··· 197 } 198 }; 199 200 - let rsvp_insert_result = rsvp_insert( 201 &web_context.pool, 202 - &create_record_result.uri, 203 - &create_record_result.cid, 204 - &current_handle.did, 205 - NSID, 206 - &the_record, 207 ) 208 .await; 209
··· 28 utils::url_from_aturi, 29 }, 30 select_template, 31 + storage::{ 32 + event::{rsvp_insert_with_metadata, RsvpInsertParams} 33 + }, 34 }; 35 use atproto_client::com::atproto::repo::{put_record, PutRecordRequest, PutRecordResponse}; 36 ··· 45 ) -> Result<impl IntoResponse, WebError> { 46 let current_handle = auth.require(&web_context.config, "/rsvp")?; 47 48 + // Check if user has email address set 49 + let identity_has_email = current_handle.email.as_ref().is_some_and(|value| !value.is_empty()); 50 + 51 let default_context = template_context! { 52 current_handle, 53 + identity_has_email, 54 language => language.to_string(), 55 canonical_url => format!("https://{}/rsvp", web_context.config.external_base), 56 hx_request, ··· 203 } 204 }; 205 206 + let rsvp_insert_result = rsvp_insert_with_metadata( 207 &web_context.pool, 208 + RsvpInsertParams { 209 + aturi: &create_record_result.uri, 210 + cid: &create_record_result.cid, 211 + did: &current_handle.did, 212 + lexicon: NSID, 213 + record: &the_record, 214 + event_aturi: build_rsvp_form.subject_aturi.as_ref().unwrap(), 215 + event_cid: build_rsvp_form.subject_cid.as_ref().unwrap(), 216 + status: build_rsvp_form.status.as_ref().unwrap(), 217 + email_shared: build_rsvp_form.email_shared.unwrap_or(false), 218 + }, 219 ) 220 .await; 221
+2
src/http/handle_import.rs
··· 232 event_aturi: &event_uri, 233 event_cid: &event_cid, 234 status, 235 }, 236 ) 237 .await; ··· 382 event_aturi: &event_uri, 383 event_cid: &event_cid, 384 status, 385 }, 386 ) 387 .await;
··· 232 event_aturi: &event_uri, 233 event_cid: &event_cid, 234 status, 235 + email_shared: false, 236 }, 237 ) 238 .await; ··· 383 event_aturi: &event_uri, 384 event_cid: &event_cid, 385 status, 386 + email_shared: false, 387 }, 388 ) 389 .await;
+15 -6
src/http/handle_view_event.rs
··· 28 use crate::storage::event::event_get; 29 use crate::storage::event::get_event_rsvps; 30 use crate::storage::event::get_user_rsvp; 31 use crate::storage::identity_profile::handle_for_did; 32 use crate::storage::identity_profile::handle_for_handle; 33 use crate::storage::identity_profile::model::IdentityProfile; ··· 125 } 126 127 let profile = profile.unwrap(); 128 129 // We'll use TimeZoneSelector to implement the time zone selection logic 130 // The timezone selection will happen after we fetch the event ··· 289 // Variables for RSVP data 290 let ( 291 user_rsvp_status, 292 going_count, 293 interested_count, 294 notgoing_count, ··· 298 user_has_standard_rsvp, 299 ) = if !is_legacy_event { 300 // Only fetch RSVP data for standard (non-legacy) events 301 - // Get user's RSVP status if logged in 302 - let user_rsvp = if let Some(current_entity) = &ctx.current_handle { 303 - match get_user_rsvp(&ctx.web_context.pool, &lookup_aturi, &current_entity.did).await { 304 - Ok(status) => status, 305 Err(err) => { 306 tracing::error!("Error getting user RSVP status: {:?}", err); 307 - None 308 } 309 } 310 } else { 311 - None 312 }; 313 314 // Get counts for all RSVP statuses ··· 371 372 ( 373 user_rsvp, 374 going_count, 375 interested_count, 376 notgoing_count, ··· 431 tracing::info!("Legacy event detected, only fetching user RSVP status"); 432 ( 433 user_rsvp, 434 0, 435 0, 436 0, ··· 455 template_context! { 456 current_handle => ctx.current_handle, 457 language => ctx.language.to_string(), 458 canonical_url => event_url, 459 event => event_with_counts, 460 is_self, ··· 464 notgoing => notgoing_handles, 465 active_tab => tab_name, 466 user_rsvp_status, 467 handle_slug, 468 event_rkey, 469 collection => collection.clone(),
··· 28 use crate::storage::event::event_get; 29 use crate::storage::event::get_event_rsvps; 30 use crate::storage::event::get_user_rsvp; 31 + use crate::storage::event::get_user_rsvp_with_email_shared; 32 use crate::storage::identity_profile::handle_for_did; 33 use crate::storage::identity_profile::handle_for_handle; 34 use crate::storage::identity_profile::model::IdentityProfile; ··· 126 } 127 128 let profile = profile.unwrap(); 129 + 130 + let identity_has_email = profile.email.is_some_and(|value| !value.is_empty()); 131 132 // We'll use TimeZoneSelector to implement the time zone selection logic 133 // The timezone selection will happen after we fetch the event ··· 292 // Variables for RSVP data 293 let ( 294 user_rsvp_status, 295 + user_email_shared, 296 going_count, 297 interested_count, 298 notgoing_count, ··· 302 user_has_standard_rsvp, 303 ) = if !is_legacy_event { 304 // Only fetch RSVP data for standard (non-legacy) events 305 + // Get user's RSVP status and email sharing preference if logged in 306 + let (user_rsvp, user_email_shared) = if let Some(current_entity) = &ctx.current_handle { 307 + match get_user_rsvp_with_email_shared(&ctx.web_context.pool, &lookup_aturi, &current_entity.did).await { 308 + Ok(Some((status, email_shared))) => (Some(status), email_shared), 309 + Ok(None) => (None, false), 310 Err(err) => { 311 tracing::error!("Error getting user RSVP status: {:?}", err); 312 + (None, false) 313 } 314 } 315 } else { 316 + (None, false) 317 }; 318 319 // Get counts for all RSVP statuses ··· 376 377 ( 378 user_rsvp, 379 + user_email_shared, 380 going_count, 381 interested_count, 382 notgoing_count, ··· 437 tracing::info!("Legacy event detected, only fetching user RSVP status"); 438 ( 439 user_rsvp, 440 + false, // Legacy events don't support email sharing 441 0, 442 0, 443 0, ··· 462 template_context! { 463 current_handle => ctx.current_handle, 464 language => ctx.language.to_string(), 465 + identity_has_email, 466 canonical_url => event_url, 467 event => event_with_counts, 468 is_self, ··· 472 notgoing => notgoing_handles, 473 active_tab => tab_name, 474 user_rsvp_status, 475 + user_email_shared, 476 handle_slug, 477 event_rkey, 478 collection => collection.clone(),
+24 -1
src/http/rsvp_form.rs
··· 1 - use serde::{Deserialize, Serialize}; 2 3 use crate::{ 4 errors::expand_error, ··· 6 storage::{event::event_get_cid, StoragePool}, 7 }; 8 9 #[derive(Serialize, Deserialize, Debug, Default, PartialEq, Clone)] 10 pub(crate) enum BuildRsvpContentState { 11 #[default] ··· 27 28 pub(crate) status: Option<String>, 29 pub(crate) status_error: Option<String>, 30 } 31 32 impl BuildRSVPForm {
··· 1 + use serde::{Deserialize, Deserializer, Serialize}; 2 3 use crate::{ 4 errors::expand_error, ··· 6 storage::{event::event_get_cid, StoragePool}, 7 }; 8 9 + #[allow(dead_code)] 10 + fn deserialize_checkbox<'de, D>(deserializer: D) -> Result<bool, D::Error> 11 + where 12 + D: Deserializer<'de>, 13 + { 14 + let s = String::deserialize(deserializer)?.to_lowercase(); 15 + Ok(s == "true" || s == "ok" || s == "on") 16 + } 17 + 18 + fn deserialize_optional_checkbox<'de, D>(deserializer: D) -> Result<Option<bool>, D::Error> 19 + where 20 + D: Deserializer<'de>, 21 + { 22 + let maybe_value: Option<String> = Option::deserialize(deserializer)?; 23 + Ok(maybe_value.map(|value| { 24 + let lower = value.to_lowercase(); 25 + lower == "true" || lower == "ok" || lower == "on" 26 + })) 27 + } 28 + 29 #[derive(Serialize, Deserialize, Debug, Default, PartialEq, Clone)] 30 pub(crate) enum BuildRsvpContentState { 31 #[default] ··· 47 48 pub(crate) status: Option<String>, 49 pub(crate) status_error: Option<String>, 50 + 51 + #[serde(deserialize_with = "deserialize_optional_checkbox")] 52 + pub(crate) email_shared: Option<bool>, 53 } 54 55 impl BuildRSVPForm {
+45 -1
src/storage/event.rs
··· 57 pub event_aturi: String, 58 pub event_cid: String, 59 pub status: String, 60 61 pub updated_at: Option<DateTime<Utc>>, 62 } ··· 132 pub event_aturi: &'a str, 133 pub event_cid: &'a str, 134 pub status: &'a str, 135 } 136 137 pub async fn rsvp_insert_with_metadata<T: serde::Serialize>( ··· 145 146 let now = Utc::now(); 147 148 - sqlx::query("INSERT INTO rsvps (aturi, cid, did, lexicon, record, event_aturi, event_cid, status, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (aturi) DO UPDATE SET record = $5, cid = $2, status = $8, updated_at = $9") 149 .bind(params.aturi) 150 .bind(params.cid) 151 .bind(params.did) ··· 154 .bind(params.event_aturi) 155 .bind(params.event_cid) 156 .bind(params.status) 157 .bind(now) 158 .execute(tx.as_mut()) 159 .await ··· 200 event_aturi: &event_aturi, 201 event_cid: &event_cid, 202 status, 203 }, 204 ) 205 .await ··· 757 .map_err(StorageError::CannotCommitDatabaseTransaction)?; 758 759 Ok(status) 760 } 761 762 pub async fn rsvp_get(pool: &StoragePool, aturi: &str) -> Result<Option<Rsvp>, StorageError> {
··· 57 pub event_aturi: String, 58 pub event_cid: String, 59 pub status: String, 60 + pub email_shared: bool, 61 62 pub updated_at: Option<DateTime<Utc>>, 63 } ··· 133 pub event_aturi: &'a str, 134 pub event_cid: &'a str, 135 pub status: &'a str, 136 + pub email_shared: bool, 137 } 138 139 pub async fn rsvp_insert_with_metadata<T: serde::Serialize>( ··· 147 148 let now = Utc::now(); 149 150 + sqlx::query("INSERT INTO rsvps (aturi, cid, did, lexicon, record, event_aturi, event_cid, status, email_shared, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT (aturi) DO UPDATE SET record = $5, cid = $2, status = $8, email_shared = $9, updated_at = $10") 151 .bind(params.aturi) 152 .bind(params.cid) 153 .bind(params.did) ··· 156 .bind(params.event_aturi) 157 .bind(params.event_cid) 158 .bind(params.status) 159 + .bind(params.email_shared) 160 .bind(now) 161 .execute(tx.as_mut()) 162 .await ··· 203 event_aturi: &event_aturi, 204 event_cid: &event_cid, 205 status, 206 + email_shared: false, 207 }, 208 ) 209 .await ··· 761 .map_err(StorageError::CannotCommitDatabaseTransaction)?; 762 763 Ok(status) 764 + } 765 + 766 + pub async fn get_user_rsvp_with_email_shared( 767 + pool: &StoragePool, 768 + event_aturi: &str, 769 + did: &str, 770 + ) -> Result<Option<(String, bool)>, StorageError> { 771 + // Validate event_aturi is not empty 772 + if event_aturi.trim().is_empty() { 773 + return Err(StorageError::UnableToExecuteQuery(sqlx::Error::Protocol( 774 + "Event URI cannot be empty".into(), 775 + ))); 776 + } 777 + 778 + // Validate did is not empty 779 + if did.trim().is_empty() { 780 + return Err(StorageError::UnableToExecuteQuery(sqlx::Error::Protocol( 781 + "DID cannot be empty".into(), 782 + ))); 783 + } 784 + 785 + let mut tx = pool 786 + .begin() 787 + .await 788 + .map_err(StorageError::CannotBeginDatabaseTransaction)?; 789 + 790 + let result = sqlx::query_as::<_, (String, bool)>( 791 + "SELECT status, email_shared FROM rsvps WHERE event_aturi = $1 AND did = $2", 792 + ) 793 + .bind(event_aturi) 794 + .bind(did) 795 + .fetch_optional(tx.as_mut()) 796 + .await 797 + .map_err(StorageError::UnableToExecuteQuery)?; 798 + 799 + tx.commit() 800 + .await 801 + .map_err(StorageError::CannotCommitDatabaseTransaction)?; 802 + 803 + Ok(result) 804 } 805 806 pub async fn rsvp_get(pool: &StoragePool, aturi: &str) -> Result<Option<Rsvp>, StorageError> {
+9
templates/create_rsvp.en-us.partial.html
··· 88 {% endif %} 89 </div> 90 91 <hr/> 92 <div class="field"> 93 <div class="control">
··· 88 {% endif %} 89 </div> 90 91 + {% if identity_has_email %} 92 + <div class="field"> 93 + <label class="checkbox"> 94 + <input type="checkbox" name="email_shared" {% if build_rsvp_form.email_shared %} checked {% endif %}> 95 + Share my email address with the event organizer 96 + </label> 97 + </div> 98 + {% endif %} 99 + 100 <hr/> 101 <div class="field"> 102 <div class="control">
+42 -120
templates/view_event.en-us.common.html
··· 241 </div> 242 </article> 243 {% elif not current_handle %} 244 - <article class="message is-success"> 245 <div class="message-body"> 246 <a href="{{ base }}/oauth/login">Log in</a> to RSVP to this 247 event. 248 </div> 249 </article> 250 {% else %} 251 - {% if not user_rsvp_status %} 252 - <article class="message" id="rsvpFrame"> 253 <div class="message-body"> 254 - <div class="columns is-vcentered is-multiline"> 255 <div class="column"> 256 <p>You have not RSVP'd.</p> 257 - </div> 258 - <div class="column"> 259 - <button class="button is-success is-fullwidth" hx-post="/rsvp" hx-target="#rsvpFrame" 260 - hx-swap="outerHTML" 261 - hx-vals='{"subject_aturi": "{{ event.aturi }}", "build_state": "Review", "status": "going"}'> 262 - <span class="icon"> 263 - <i class="fas fa-star"></i> 264 - </span> 265 - <span>Going</span> 266 - </button> 267 - </div> 268 - <div class="column"> 269 - <button class="button is-link is-fullwidth" hx-post="/rsvp" hx-target="#rsvpFrame" 270 - hx-swap="outerHTML" 271 - hx-vals='{"subject_aturi": "{{ event.aturi }}", "build_state": "Review", "status": "interested"}'> 272 - <span class="icon"> 273 - <i class="fas fa-eye"></i> 274 - </span> 275 - <span>Interested</span> 276 - </button> 277 - </div> 278 - <div class="column"> 279 - <button class="button is-warning is-fullwidth" hx-post="/rsvp" hx-target="#rsvpFrame" 280 - hx-swap="outerHTML" 281 - hx-vals='{"subject_aturi": "{{ event.aturi }}", "build_state": "Review", "status": "notgoing"}'> 282 - <span class="icon"> 283 - <i class="fas fa-ban"></i> 284 - </span> 285 - <span>Not Going</span> 286 - </button> 287 - </div> 288 - </div> 289 - </div> 290 - </article> 291 - {% elif user_rsvp_status == "going" %} 292 - <article class="message is-info" id="rsvpFrame"> 293 - <div class="message-body"> 294 - <div class="columns is-vcentered is-multiline"> 295 - <div class="column"> 296 <p>You have RSVP'd <strong>Going</strong>.</p> 297 - </div> 298 - <div class="column"> 299 - <button class="button is-link is-fullwidth" hx-post="/rsvp" hx-target="#rsvpFrame" 300 - hx-swap="outerHTML" 301 - hx-vals='{"subject_aturi": "{{ event.aturi }}", "build_state": "Review", "status": "interested"}'> 302 - <span class="icon"> 303 - <i class="fas fa-eye"></i> 304 - </span> 305 - <span>Interested</span> 306 - </button> 307 - </div> 308 - <div class="column"> 309 - <button class="button is-warning is-fullwidth" hx-post="/rsvp" hx-target="#rsvpFrame" 310 - hx-swap="outerHTML" 311 - hx-vals='{"subject_aturi": "{{ event.aturi }}", "build_state": "Review", "status": "notgoing"}'> 312 - <span class="icon"> 313 - <i class="fas fa-ban"></i> 314 - </span> 315 - <span>Not Going</span> 316 - </button> 317 - </div> 318 - </div> 319 - </div> 320 - </article> 321 - {% elif user_rsvp_status == "interested" %} 322 - <article class="message is-info" id="rsvpFrame"> 323 - <div class="message-body"> 324 - <div class="columns is-vcentered is-multiline"> 325 - <div class="column"> 326 <p>You have RSVP'd <strong>Interested</strong>.</p> 327 - </div> 328 - <div class="column"> 329 - <button class="button is-success is-fullwidth" hx-post="/rsvp" hx-target="#rsvpFrame" 330 - hx-swap="outerHTML" 331 - hx-vals='{"subject_aturi": "{{ event.aturi }}", "build_state": "Review", "status": "going"}'> 332 - <span class="icon"> 333 - <i class="fas fa-star"></i> 334 - </span> 335 - <span>Going</span> 336 - </button> 337 </div> 338 - <div class="column"> 339 - <button class="button is-warning is-fullwidth" hx-post="/rsvp" hx-target="#rsvpFrame" 340 - hx-swap="outerHTML" 341 - hx-vals='{"subject_aturi": "{{ event.aturi }}", "build_state": "Review", "status": "notgoing"}'> 342 - <span class="icon"> 343 - <i class="fas fa-ban"></i> 344 - </span> 345 - <span>Not Going</span> 346 - </button> 347 - </div> 348 - </div> 349 - </div> 350 - </article> 351 - {% elif user_rsvp_status == "notgoing" %} 352 - <article class="message is-warning" id="rsvpFrame"> 353 - <div class="message-body"> 354 - <div class="columns is-vcentered is-multiline"> 355 - <div class="column"> 356 - <p>You have RSVP'd <strong>Not Going</strong>.</p> 357 </div> 358 <div class="column"> 359 - <button class="button is-success is-fullwidth" hx-post="/rsvp" hx-target="#rsvpFrame" 360 - hx-swap="outerHTML" 361 - hx-vals='{"subject_aturi": "{{ event.aturi }}", "build_state": "Review", "status": "going"}'> 362 - <span class="icon"> 363 - <i class="fas fa-star"></i> 364 - </span> 365 - <span>Going</span> 366 - </button> 367 - </div> 368 - <div class="column"> 369 - <button class="button is-link is-fullwidth" hx-post="/rsvp" hx-target="#rsvpFrame" 370 - hx-swap="outerHTML" 371 - hx-vals='{"subject_aturi": "{{ event.aturi }}", "build_state": "Review", "status": "interested"}'> 372 - <span class="icon"> 373 - <i class="fas fa-eye"></i> 374 - </span> 375 - <span>Interested</span> 376 - </button> 377 </div> 378 </div> 379 </div> 380 </article> 381 - {% endif %} 382 {% endif %} 383 </div> 384 </section>
··· 241 </div> 242 </article> 243 {% elif not current_handle %} 244 + <article class="message"> 245 <div class="message-body"> 246 <a href="{{ base }}/oauth/login">Log in</a> to RSVP to this 247 event. 248 </div> 249 </article> 250 {% else %} 251 + 252 + <article class="message {% if user_rsvp_status %}is-success{% endif %}" id="rsvpFrame"> 253 <div class="message-body"> 254 + <div class="columns"> 255 <div class="column"> 256 + {% if not user_rsvp_status %} 257 <p>You have not RSVP'd.</p> 258 + {% elif user_rsvp_status == "going" %} 259 <p>You have RSVP'd <strong>Going</strong>.</p> 260 + {% elif user_rsvp_status == "interested" %} 261 <p>You have RSVP'd <strong>Interested</strong>.</p> 262 + {% elif user_rsvp_status == "notgoing" %} 263 + <p>You have RSVP'd <strong>Not Goiing</strong>.</p> 264 + {% endif %} 265 </div> 266 + <div class="column is-half"> 267 + <div class="field"> 268 + <div class="control is-expanded"> 269 + <div class="select is-fullwidth"> 270 + <select id="rsvp_status" name="status"> 271 + <option value="going"{% if user_rsvp_status == "going" %} selected="selected"{% endif %}>Going</option> 272 + <option value="interested"{% if user_rsvp_status == "interested" %} selected="selected"{% endif %}>Interested</option> 273 + <option value="notgoing"{% if user_rsvp_status == "notgoing" %} selected="selected"{% endif %}>Not Going</option> 274 + </select> 275 + </div> 276 + </div> 277 + </div> 278 + {% if identity_has_email %} 279 + <div class="field"> 280 + <div class="control is-expanded"> 281 + <label class="checkbox is-fullwidth"> 282 + <input type="checkbox" id="email_shared_checkbox" name="email_shared"{% if user_email_shared %} checked{% endif %}> 283 + Share my email address with the event organizer 284 + </label> 285 + </div> 286 + </div> 287 + {% endif %} 288 </div> 289 <div class="column"> 290 + <div class="field"> 291 + <div class="control is-expanded"> 292 + <button class="button is-primary is-fullwidth" hx-post="/rsvp" hx-target="#rsvpFrame" 293 + hx-swap="outerHTML" 294 + hx-vals='{"subject_aturi": "{{ event.aturi }}", "build_state": "Review"}' 295 + hx-include="#email_shared_checkbox, #rsvp_status"> 296 + RSVP 297 + </button> 298 + </div> 299 + </div> 300 </div> 301 </div> 302 </div> 303 </article> 304 {% endif %} 305 </div> 306 </section>