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 1 { 2 2 "files.associations": { 3 3 "*.html": "jinja-html" 4 - } 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 + ] 5 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 236 event_aturi: &event_aturi, 237 237 event_cid: &event_cid, 238 238 status, 239 + email_shared: false, 239 240 }, 240 241 ) 241 242 .await ··· 283 284 event_aturi: &event_aturi, 284 285 event_cid: &event_cid, 285 286 status, 287 + email_shared: false, 286 288 }, 287 289 ) 288 290 .await
+19 -7
src/http/handle_create_rsvp.rs
··· 28 28 utils::url_from_aturi, 29 29 }, 30 30 select_template, 31 - storage::event::rsvp_insert, 31 + storage::{ 32 + event::{rsvp_insert_with_metadata, RsvpInsertParams} 33 + }, 32 34 }; 33 35 use atproto_client::com::atproto::repo::{put_record, PutRecordRequest, PutRecordResponse}; 34 36 ··· 43 45 ) -> Result<impl IntoResponse, WebError> { 44 46 let current_handle = auth.require(&web_context.config, "/rsvp")?; 45 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 + 46 51 let default_context = template_context! { 47 52 current_handle, 53 + identity_has_email, 48 54 language => language.to_string(), 49 55 canonical_url => format!("https://{}/rsvp", web_context.config.external_base), 50 56 hx_request, ··· 197 203 } 198 204 }; 199 205 200 - let rsvp_insert_result = rsvp_insert( 206 + let rsvp_insert_result = rsvp_insert_with_metadata( 201 207 &web_context.pool, 202 - &create_record_result.uri, 203 - &create_record_result.cid, 204 - &current_handle.did, 205 - NSID, 206 - &the_record, 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 + }, 207 219 ) 208 220 .await; 209 221
+2
src/http/handle_import.rs
··· 232 232 event_aturi: &event_uri, 233 233 event_cid: &event_cid, 234 234 status, 235 + email_shared: false, 235 236 }, 236 237 ) 237 238 .await; ··· 382 383 event_aturi: &event_uri, 383 384 event_cid: &event_cid, 384 385 status, 386 + email_shared: false, 385 387 }, 386 388 ) 387 389 .await;
+15 -6
src/http/handle_view_event.rs
··· 28 28 use crate::storage::event::event_get; 29 29 use crate::storage::event::get_event_rsvps; 30 30 use crate::storage::event::get_user_rsvp; 31 + use crate::storage::event::get_user_rsvp_with_email_shared; 31 32 use crate::storage::identity_profile::handle_for_did; 32 33 use crate::storage::identity_profile::handle_for_handle; 33 34 use crate::storage::identity_profile::model::IdentityProfile; ··· 125 126 } 126 127 127 128 let profile = profile.unwrap(); 129 + 130 + let identity_has_email = profile.email.is_some_and(|value| !value.is_empty()); 128 131 129 132 // We'll use TimeZoneSelector to implement the time zone selection logic 130 133 // The timezone selection will happen after we fetch the event ··· 289 292 // Variables for RSVP data 290 293 let ( 291 294 user_rsvp_status, 295 + user_email_shared, 292 296 going_count, 293 297 interested_count, 294 298 notgoing_count, ··· 298 302 user_has_standard_rsvp, 299 303 ) = if !is_legacy_event { 300 304 // 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 + // 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), 305 310 Err(err) => { 306 311 tracing::error!("Error getting user RSVP status: {:?}", err); 307 - None 312 + (None, false) 308 313 } 309 314 } 310 315 } else { 311 - None 316 + (None, false) 312 317 }; 313 318 314 319 // Get counts for all RSVP statuses ··· 371 376 372 377 ( 373 378 user_rsvp, 379 + user_email_shared, 374 380 going_count, 375 381 interested_count, 376 382 notgoing_count, ··· 431 437 tracing::info!("Legacy event detected, only fetching user RSVP status"); 432 438 ( 433 439 user_rsvp, 440 + false, // Legacy events don't support email sharing 434 441 0, 435 442 0, 436 443 0, ··· 455 462 template_context! { 456 463 current_handle => ctx.current_handle, 457 464 language => ctx.language.to_string(), 465 + identity_has_email, 458 466 canonical_url => event_url, 459 467 event => event_with_counts, 460 468 is_self, ··· 464 472 notgoing => notgoing_handles, 465 473 active_tab => tab_name, 466 474 user_rsvp_status, 475 + user_email_shared, 467 476 handle_slug, 468 477 event_rkey, 469 478 collection => collection.clone(),
+24 -1
src/http/rsvp_form.rs
··· 1 - use serde::{Deserialize, Serialize}; 1 + use serde::{Deserialize, Deserializer, Serialize}; 2 2 3 3 use crate::{ 4 4 errors::expand_error, ··· 6 6 storage::{event::event_get_cid, StoragePool}, 7 7 }; 8 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 + 9 29 #[derive(Serialize, Deserialize, Debug, Default, PartialEq, Clone)] 10 30 pub(crate) enum BuildRsvpContentState { 11 31 #[default] ··· 27 47 28 48 pub(crate) status: Option<String>, 29 49 pub(crate) status_error: Option<String>, 50 + 51 + #[serde(deserialize_with = "deserialize_optional_checkbox")] 52 + pub(crate) email_shared: Option<bool>, 30 53 } 31 54 32 55 impl BuildRSVPForm {
+45 -1
src/storage/event.rs
··· 57 57 pub event_aturi: String, 58 58 pub event_cid: String, 59 59 pub status: String, 60 + pub email_shared: bool, 60 61 61 62 pub updated_at: Option<DateTime<Utc>>, 62 63 } ··· 132 133 pub event_aturi: &'a str, 133 134 pub event_cid: &'a str, 134 135 pub status: &'a str, 136 + pub email_shared: bool, 135 137 } 136 138 137 139 pub async fn rsvp_insert_with_metadata<T: serde::Serialize>( ··· 145 147 146 148 let now = Utc::now(); 147 149 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") 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") 149 151 .bind(params.aturi) 150 152 .bind(params.cid) 151 153 .bind(params.did) ··· 154 156 .bind(params.event_aturi) 155 157 .bind(params.event_cid) 156 158 .bind(params.status) 159 + .bind(params.email_shared) 157 160 .bind(now) 158 161 .execute(tx.as_mut()) 159 162 .await ··· 200 203 event_aturi: &event_aturi, 201 204 event_cid: &event_cid, 202 205 status, 206 + email_shared: false, 203 207 }, 204 208 ) 205 209 .await ··· 757 761 .map_err(StorageError::CannotCommitDatabaseTransaction)?; 758 762 759 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) 760 804 } 761 805 762 806 pub async fn rsvp_get(pool: &StoragePool, aturi: &str) -> Result<Option<Rsvp>, StorageError> {
+9
templates/create_rsvp.en-us.partial.html
··· 88 88 {% endif %} 89 89 </div> 90 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 + 91 100 <hr/> 92 101 <div class="field"> 93 102 <div class="control">
+42 -120
templates/view_event.en-us.common.html
··· 241 241 </div> 242 242 </article> 243 243 {% elif not current_handle %} 244 - <article class="message is-success"> 244 + <article class="message"> 245 245 <div class="message-body"> 246 246 <a href="{{ base }}/oauth/login">Log in</a> to RSVP to this 247 247 event. 248 248 </div> 249 249 </article> 250 250 {% else %} 251 - {% if not user_rsvp_status %} 252 - <article class="message" id="rsvpFrame"> 251 + 252 + <article class="message {% if user_rsvp_status %}is-success{% endif %}" id="rsvpFrame"> 253 253 <div class="message-body"> 254 - <div class="columns is-vcentered is-multiline"> 254 + <div class="columns"> 255 255 <div class="column"> 256 + {% if not user_rsvp_status %} 256 257 <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"> 258 + {% elif user_rsvp_status == "going" %} 296 259 <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"> 260 + {% elif user_rsvp_status == "interested" %} 326 261 <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> 262 + {% elif user_rsvp_status == "notgoing" %} 263 + <p>You have RSVP'd <strong>Not Goiing</strong>.</p> 264 + {% endif %} 337 265 </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> 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 %} 357 288 </div> 358 289 <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> 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> 377 300 </div> 378 301 </div> 379 302 </div> 380 303 </article> 381 - {% endif %} 382 304 {% endif %} 383 305 </div> 384 306 </section>