The smokesignal.events web application

feature: rsvp management

+3425 -648
+1
Cargo.toml
··· 91 91 unic-langid = "0.9" 92 92 url = "2.5" 93 93 urlencoding = "2.1" 94 + base32 = "0.5.1" 94 95 95 96 [profile.release] 96 97 opt-level = 3
+98
src/atproto/lexicon/acceptance.rs
··· 1 1 use atproto_record::typed::{LexiconType, TypedLexicon}; 2 2 use serde::{Deserialize, Serialize}; 3 + use std::collections::HashMap; 3 4 4 5 pub const NSID: &str = "events.smokesignal.calendar.acceptance"; 5 6 ··· 9 10 pub struct Acceptance { 10 11 /// The CID (Content Identifier) of the RSVP that this proof validates 11 12 pub cid: String, 13 + /// Optional custom metadata fields (e.g., table_number, meal_preference) 14 + #[serde(flatten)] 15 + pub extra: HashMap<String, serde_json::Value>, 12 16 } 13 17 14 18 pub type TypedAcceptance = TypedLexicon<Acceptance>; ··· 28 32 Ok(()) 29 33 } 30 34 } 35 + 36 + #[cfg(test)] 37 + mod tests { 38 + use super::*; 39 + use serde_json::json; 40 + 41 + #[test] 42 + fn test_acceptance_without_metadata() { 43 + let acceptance = Acceptance { 44 + cid: "bafyreihqz5qguz5x5q6v3mqz6k7u6hnzkxqz5".to_string(), 45 + extra: HashMap::new(), 46 + }; 47 + 48 + assert_eq!(acceptance.cid, "bafyreihqz5qguz5x5q6v3mqz6k7u6hnzkxqz5"); 49 + assert!(acceptance.extra.is_empty()); 50 + assert!(acceptance.validate().is_ok()); 51 + } 52 + 53 + #[test] 54 + fn test_acceptance_with_metadata() { 55 + let mut metadata = HashMap::new(); 56 + metadata.insert("table_number".to_string(), json!("5")); 57 + metadata.insert("meal_preference".to_string(), json!("vegetarian")); 58 + metadata.insert("parking_pass".to_string(), json!("A123")); 59 + 60 + let acceptance = Acceptance { 61 + cid: "bafyreihqz5qguz5x5q6v3mqz6k7u6hnzkxqz5".to_string(), 62 + extra: metadata.clone(), 63 + }; 64 + 65 + assert_eq!(acceptance.extra.len(), 3); 66 + assert_eq!(acceptance.extra.get("table_number").unwrap(), &json!("5")); 67 + assert_eq!( 68 + acceptance.extra.get("meal_preference").unwrap(), 69 + &json!("vegetarian") 70 + ); 71 + assert!(acceptance.validate().is_ok()); 72 + } 73 + 74 + #[test] 75 + fn test_acceptance_serialization() { 76 + let mut metadata = HashMap::new(); 77 + metadata.insert("table_number".to_string(), json!("5")); 78 + 79 + let acceptance = Acceptance { 80 + cid: "test_cid".to_string(), 81 + extra: metadata, 82 + }; 83 + 84 + let serialized = serde_json::to_string(&acceptance).unwrap(); 85 + assert!(serialized.contains("\"cid\":\"test_cid\"")); 86 + assert!(serialized.contains("\"table_number\":\"5\"")); 87 + // Check that extra fields are flattened (not nested under "extra") 88 + assert!(!serialized.contains("\"extra\"")); 89 + } 90 + 91 + #[test] 92 + fn test_acceptance_deserialization() { 93 + let json_str = r#"{"cid":"test_cid","table_number":"5","meal_preference":"vegetarian"}"#; 94 + let acceptance: Acceptance = serde_json::from_str(json_str).unwrap(); 95 + 96 + assert_eq!(acceptance.cid, "test_cid"); 97 + assert_eq!(acceptance.extra.len(), 2); 98 + assert_eq!(acceptance.extra.get("table_number").unwrap(), &json!("5")); 99 + assert_eq!( 100 + acceptance.extra.get("meal_preference").unwrap(), 101 + &json!("vegetarian") 102 + ); 103 + } 104 + 105 + #[test] 106 + fn test_acceptance_validation_empty_cid() { 107 + let acceptance = Acceptance { 108 + cid: "".to_string(), 109 + extra: HashMap::new(), 110 + }; 111 + 112 + assert!(acceptance.validate().is_err()); 113 + assert_eq!( 114 + acceptance.validate().unwrap_err(), 115 + "CID cannot be empty".to_string() 116 + ); 117 + } 118 + 119 + #[test] 120 + fn test_acceptance_validation_whitespace_cid() { 121 + let acceptance = Acceptance { 122 + cid: " ".to_string(), 123 + extra: HashMap::new(), 124 + }; 125 + 126 + assert!(acceptance.validate().is_err()); 127 + } 128 + }
+9 -4
src/bin/smokesignal.rs
··· 44 44 #[cfg(feature = "embed")] 45 45 use smokesignal::http::templates::embed_env; 46 46 47 - #[cfg(feature = "reload")] 47 + #[cfg(all(feature = "reload", not(feature = "embed")))] 48 48 use smokesignal::i18n::reload::populate_locale; 49 49 50 - #[cfg(feature = "reload")] 50 + #[cfg(all(feature = "reload", not(feature = "embed")))] 51 51 use smokesignal::http::templates::reload_env; 52 52 53 53 #[tokio::main] ··· 96 96 #[cfg(feature = "embed")] 97 97 let jinja = embed_env::build_env(config.external_base.clone(), config.version.clone()); 98 98 99 - #[cfg(feature = "reload")] 99 + #[cfg(all(feature = "reload", not(feature = "embed")))] 100 100 let jinja = reload_env::build_env(&config.external_base, &config.version); 101 101 102 102 // Initialize the DNS resolver with configuration from the app config ··· 390 390 compression: false, 391 391 zstd_dictionary_location: String::new(), 392 392 jetstream_hostname: inner_config.jetstream_hostname.clone(), 393 - collections: vec!["community.lexicon.calendar.rsvp".to_string(), "community.lexicon.calendar.event".to_string(), "events.smokesignal.profile".to_string()], 393 + collections: vec![ 394 + "community.lexicon.calendar.rsvp".to_string(), 395 + "community.lexicon.calendar.event".to_string(), 396 + "events.smokesignal.profile".to_string(), 397 + "events.smokesignal.calendar.acceptance".to_string() 398 + ], 394 399 dids: vec![], 395 400 max_message_size_bytes: Some(20 * 1024 * 1024), // 10MB 396 401 cursor: None,
+363
src/http/acceptance_operations.rs
··· 1 + use anyhow::Result; 2 + use atproto_attestation::cid::create_attestation_cid; 3 + use atproto_client::com::atproto::repo::{PutRecordRequest, PutRecordResponse, put_record}; 4 + use atproto_record::{ 5 + lexicon::community::lexicon::calendar::rsvp::{Rsvp, TypedRsvp}, 6 + typed::TypedLexicon, 7 + }; 8 + use metrohash::MetroHash64; 9 + use std::collections::HashMap; 10 + use std::hash::Hasher; 11 + 12 + use crate::{ 13 + atproto::lexicon::acceptance::{Acceptance, NSID as ACCEPTANCE_NSID, TypedAcceptance}, 14 + http::context::WebContext, 15 + storage::acceptance::{ 16 + acceptance_record_delete, acceptance_ticket_get_by_event_and_rsvp_did, 17 + acceptance_ticket_upsert, rsvp_update_validated_at, 18 + }, 19 + }; 20 + 21 + /// Generate a deterministic record key from an RSVP AT-URI 22 + /// 23 + /// Uses MetroHash64 to create a fast, deterministic hash of the RSVP AT-URI, 24 + /// then encodes it as base32 (lowercase) to create a valid AT Protocol record key. 25 + /// 26 + /// This ensures: 27 + /// - Same RSVP always maps to same acceptance record key (idempotency) 28 + /// - Different RSVPs get different keys (collision-resistant) 29 + /// - Keys are valid for AT Protocol (alphanumeric + lowercase) 30 + fn generate_acceptance_record_key(rsvp_aturi: &str) -> String { 31 + let mut hasher = MetroHash64::new(); 32 + hasher.write(rsvp_aturi.as_bytes()); 33 + let hash = hasher.finish(); 34 + 35 + // Convert u64 hash to base32 lowercase for a valid record key 36 + // Using base32 gives us a shorter, URL-safe string 37 + base32::encode( 38 + base32::Alphabet::Rfc4648 { padding: false }, 39 + &hash.to_le_bytes(), 40 + ) 41 + .to_lowercase() 42 + } 43 + 44 + /// Result of checking if an acceptance can be updated 45 + #[derive(Debug)] 46 + pub enum AcceptanceStatus { 47 + /// No existing acceptance - should create new 48 + NotExists, 49 + /// Draft acceptance exists - can be updated 50 + CanUpdate { existing_aturi: String }, 51 + /// Acceptance has been finalized by attendee - should NOT update 52 + Finalized, 53 + } 54 + 55 + /// Check if an acceptance exists and whether it can be updated 56 + pub async fn check_acceptance_status( 57 + web_context: &WebContext, 58 + event_aturi: &str, 59 + rsvp_did: &str, 60 + ) -> Result<AcceptanceStatus> { 61 + // Check if there's an existing acceptance ticket (draft) 62 + match acceptance_ticket_get_by_event_and_rsvp_did(&web_context.pool, event_aturi, rsvp_did) 63 + .await? 64 + { 65 + Some(ticket) => { 66 + // Ticket exists - check if RSVP has been validated (finalized) 67 + // Get the RSVP by event and attendee DID to check validated_at status 68 + if crate::storage::event::rsvp_get_by_event_and_did( 69 + &web_context.pool, 70 + event_aturi, 71 + rsvp_did, 72 + ) 73 + .await? 74 + .filter(|r| r.validated_at.is_some()) 75 + .is_some() 76 + { 77 + // RSVP has been validated - acceptance is finalized 78 + return Ok(AcceptanceStatus::Finalized); 79 + } 80 + 81 + // Draft exists and not finalized - can update 82 + Ok(AcceptanceStatus::CanUpdate { 83 + existing_aturi: ticket.aturi, 84 + }) 85 + } 86 + None => { 87 + // No existing acceptance ticket 88 + Ok(AcceptanceStatus::NotExists) 89 + } 90 + } 91 + } 92 + 93 + /// Create or update an acceptance record using deterministic record keys 94 + /// 95 + /// This function uses a deterministic record key based on the RSVP AT-URI, 96 + /// which provides natural idempotency - calling this multiple times for the 97 + /// same RSVP will update the existing acceptance rather than creating duplicates. 98 + /// 99 + /// Note: Organizers can update acceptances even after they've been finalized by attendees. 100 + /// This allows organizers to update metadata like table assignments or other event details. 101 + pub async fn create_or_update_acceptance( 102 + web_context: &WebContext, 103 + dpop_auth: atproto_client::client::DPoPAuth, 104 + current_did: &str, 105 + current_pds: &str, 106 + rsvp: &crate::storage::event::model::Rsvp, 107 + rsvp_record: &Rsvp, 108 + metadata: &HashMap<String, serde_json::Value>, 109 + ) -> Result<(String, bool)> { 110 + // Generate deterministic record key from RSVP AT-URI 111 + let record_key = generate_acceptance_record_key(&rsvp.aturi); 112 + 113 + // Try to get existing record to determine if this is create or update 114 + let existing_record = atproto_client::com::atproto::repo::get_record( 115 + &web_context.http_client, 116 + &atproto_client::client::Auth::None, 117 + current_pds, 118 + current_did, 119 + ACCEPTANCE_NSID, 120 + &record_key, 121 + None, 122 + ) 123 + .await; 124 + 125 + let (swap_cid, is_update) = match existing_record { 126 + Ok(atproto_client::com::atproto::repo::GetRecordResponse::Record { cid, .. }) => { 127 + (Some(cid), true) 128 + } 129 + _ => (None, false), 130 + }; 131 + 132 + // Create the acceptance record with metadata FIRST 133 + // We need to build this before computing the CID because the CID must include ALL fields 134 + let acceptance = Acceptance { 135 + cid: String::new(), // Placeholder - will be replaced 136 + extra: metadata.clone(), 137 + }; 138 + 139 + let typed_acceptance = TypedAcceptance::new(acceptance.clone()); 140 + 141 + // Serialize to get the full metadata structure that will be in the acceptance record 142 + // This includes $type, and all metadata fields (but not 'cid' yet) 143 + let mut acceptance_metadata = serde_json::to_value(&typed_acceptance) 144 + .map_err(|e| anyhow::anyhow!("Failed to serialize acceptance: {}", e))?; 145 + 146 + // Remove the placeholder cid field (empty string) from the metadata 147 + if let serde_json::Value::Object(ref mut map) = acceptance_metadata { 148 + map.remove("cid"); 149 + } 150 + 151 + // NOW create attestation CID with the full metadata structure 152 + let typed_rsvp: TypedRsvp = TypedLexicon::new(rsvp_record.clone()); 153 + let content_cid = 154 + create_attestation_cid(typed_rsvp.into(), acceptance_metadata.into(), current_did) 155 + .map_err(|e| anyhow::anyhow!("Failed to create remote attestation proof: {}", e))?; 156 + 157 + // Update the acceptance with the correct CID 158 + let acceptance = Acceptance { 159 + cid: content_cid.to_string(), 160 + extra: metadata.clone(), 161 + }; 162 + 163 + let typed_acceptance = TypedAcceptance::new(acceptance.clone()); 164 + 165 + // Put record (create or update) with deterministic key 166 + let put_request = PutRecordRequest { 167 + repo: current_did.to_string(), 168 + collection: ACCEPTANCE_NSID.to_string(), 169 + validate: false, 170 + record_key: record_key.clone(), 171 + record: typed_acceptance.clone(), 172 + swap_commit: None, 173 + swap_record: swap_cid, // Use swap_record to prevent race conditions 174 + }; 175 + 176 + let put_result = put_record( 177 + &web_context.http_client, 178 + &atproto_client::client::Auth::DPoP(dpop_auth), 179 + current_pds, 180 + put_request, 181 + ) 182 + .await?; 183 + 184 + match put_result { 185 + PutRecordResponse::StrongRef { uri, .. } => Ok((uri, is_update)), 186 + PutRecordResponse::Error(err) => Err(anyhow::anyhow!( 187 + "AT Protocol server rejected acceptance: {}", 188 + err.error_message() 189 + )), 190 + } 191 + } 192 + 193 + /// Store or update the acceptance ticket in local database 194 + pub async fn store_acceptance_ticket( 195 + web_context: &WebContext, 196 + acceptance_uri: &str, 197 + organizer_did: &str, 198 + rsvp_did: &str, 199 + event_aturi: &str, 200 + acceptance: &Acceptance, 201 + ) -> Result<()> { 202 + acceptance_ticket_upsert( 203 + &web_context.pool, 204 + acceptance_uri, 205 + organizer_did, 206 + rsvp_did, 207 + event_aturi, 208 + acceptance, 209 + ) 210 + .await 211 + .map_err(|e| anyhow::anyhow!("Failed to store acceptance ticket: {}", e)) 212 + } 213 + 214 + /// Delete an acceptance record (unaccept an RSVP) 215 + /// 216 + /// This removes the acceptance record from AT Protocol and deletes the acceptance ticket 217 + /// from the local database. This allows organizers to revoke an acceptance. 218 + pub async fn delete_acceptance( 219 + web_context: &WebContext, 220 + dpop_auth: atproto_client::client::DPoPAuth, 221 + current_did: &str, 222 + current_pds: &str, 223 + rsvp_aturi: &str, 224 + event_aturi: &str, 225 + rsvp_did: &str, 226 + ) -> Result<()> { 227 + // Generate deterministic record key from RSVP AT-URI 228 + let record_key = generate_acceptance_record_key(rsvp_aturi); 229 + 230 + // Delete the record from AT Protocol 231 + let delete_request = atproto_client::com::atproto::repo::DeleteRecordRequest { 232 + repo: current_did.to_string(), 233 + collection: ACCEPTANCE_NSID.to_string(), 234 + record_key: record_key.clone(), 235 + swap_commit: None, 236 + swap_record: None, 237 + }; 238 + 239 + let delete_result = atproto_client::com::atproto::repo::delete_record( 240 + &web_context.http_client, 241 + &atproto_client::client::Auth::DPoP(dpop_auth), 242 + current_pds, 243 + delete_request, 244 + ) 245 + .await?; 246 + 247 + match delete_result { 248 + atproto_client::com::atproto::repo::DeleteRecordResponse::Commit { .. } => { 249 + // Construct the acceptance AT-URI 250 + let acceptance_aturi = 251 + format!("at://{}/{}/{}", current_did, ACCEPTANCE_NSID, record_key); 252 + 253 + // Delete acceptance record from local database 254 + acceptance_record_delete(&web_context.pool, &acceptance_aturi) 255 + .await 256 + .map_err(|e| anyhow::anyhow!("Failed to delete acceptance record: {}", e))?; 257 + 258 + // Delete acceptance ticket from local database 259 + crate::storage::acceptance::acceptance_ticket_delete_by_event_and_rsvp_did( 260 + &web_context.pool, 261 + event_aturi, 262 + rsvp_did, 263 + ) 264 + .await 265 + .map_err(|e| anyhow::anyhow!("Failed to delete acceptance ticket: {}", e))?; 266 + 267 + // Clear the validated_at timestamp on the RSVP record 268 + rsvp_update_validated_at(&web_context.pool, rsvp_aturi, None) 269 + .await 270 + .map_err(|e| anyhow::anyhow!("Failed to clear RSVP validated_at: {}", e))?; 271 + 272 + Ok(()) 273 + } 274 + atproto_client::com::atproto::repo::DeleteRecordResponse::Error(err) => { 275 + Err(anyhow::anyhow!( 276 + "AT Protocol server rejected deletion: {}", 277 + err.error_message() 278 + )) 279 + } 280 + } 281 + } 282 + 283 + #[cfg(test)] 284 + mod tests { 285 + use super::*; 286 + 287 + #[test] 288 + fn test_generate_acceptance_record_key_deterministic() { 289 + let rsvp_aturi = "at://did:plc:abc123/community.lexicon.calendar.rsvp/xyz789"; 290 + 291 + // Same input should always produce same output 292 + let key1 = generate_acceptance_record_key(rsvp_aturi); 293 + let key2 = generate_acceptance_record_key(rsvp_aturi); 294 + 295 + assert_eq!(key1, key2); 296 + assert!(!key1.is_empty()); 297 + } 298 + 299 + #[test] 300 + fn test_generate_acceptance_record_key_unique() { 301 + let rsvp1 = "at://did:plc:abc123/community.lexicon.calendar.rsvp/xyz789"; 302 + let rsvp2 = "at://did:plc:def456/community.lexicon.calendar.rsvp/uvw012"; 303 + 304 + let key1 = generate_acceptance_record_key(rsvp1); 305 + let key2 = generate_acceptance_record_key(rsvp2); 306 + 307 + // Different inputs should produce different outputs 308 + assert_ne!(key1, key2); 309 + } 310 + 311 + #[test] 312 + fn test_generate_acceptance_record_key_format() { 313 + let rsvp_aturi = "at://did:plc:test/community.lexicon.calendar.rsvp/test123"; 314 + let key = generate_acceptance_record_key(rsvp_aturi); 315 + 316 + // Key should be lowercase base32 (no padding) 317 + assert!( 318 + key.chars() 319 + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()) 320 + ); 321 + assert!(!key.contains('=')); // No padding 322 + 323 + // Should be reasonable length (13 chars for base32-encoded u64) 324 + assert!(key.len() <= 20); 325 + assert!(key.len() >= 10); 326 + } 327 + 328 + #[test] 329 + fn test_generate_acceptance_record_key_collision_resistance() { 330 + // Test with very similar AT-URIs to ensure no easy collisions 331 + let rsvp1 = "at://did:plc:test/community.lexicon.calendar.rsvp/abc123"; 332 + let rsvp2 = "at://did:plc:test/community.lexicon.calendar.rsvp/abc124"; 333 + 334 + let key1 = generate_acceptance_record_key(rsvp1); 335 + let key2 = generate_acceptance_record_key(rsvp2); 336 + 337 + assert_ne!(key1, key2); 338 + } 339 + 340 + #[test] 341 + fn test_generate_acceptance_record_key_consistency_across_operations() { 342 + // Verify that accept and unaccept would target the same record 343 + let rsvp_aturi = "at://did:plc:user123/community.lexicon.calendar.rsvp/rsvp123"; 344 + 345 + let key_for_create = generate_acceptance_record_key(rsvp_aturi); 346 + let key_for_delete = generate_acceptance_record_key(rsvp_aturi); 347 + 348 + // Keys must match to ensure delete targets the same record created by accept 349 + assert_eq!(key_for_create, key_for_delete); 350 + } 351 + 352 + #[test] 353 + fn test_generate_acceptance_record_key_different_users() { 354 + // Different users RSVPing to same event should have different acceptance keys 355 + let rsvp1 = "at://did:plc:user1/community.lexicon.calendar.rsvp/rsvp123"; 356 + let rsvp2 = "at://did:plc:user2/community.lexicon.calendar.rsvp/rsvp123"; 357 + 358 + let key1 = generate_acceptance_record_key(rsvp1); 359 + let key2 = generate_acceptance_record_key(rsvp2); 360 + 361 + assert_ne!(key1, key2); 362 + } 363 + }
+5 -5
src/http/context.rs
··· 17 17 use tokio::sync::mpsc; 18 18 use unic_langid::LanguageIdentifier; 19 19 20 - #[cfg(feature = "reload")] 20 + #[cfg(all(feature = "reload", not(feature = "embed")))] 21 21 use minijinja_autoreload::AutoReloader; 22 22 23 - #[cfg(feature = "reload")] 23 + #[cfg(all(feature = "reload", not(feature = "embed")))] 24 24 pub type AppEngine = Engine<AutoReloader>; 25 25 26 26 #[cfg(feature = "embed")] 27 27 use minijinja::Environment; 28 + 29 + #[cfg(feature = "embed")] 30 + pub type AppEngine = Engine<Environment<'static>>; 28 31 29 32 use crate::emailer::Emailer; 30 33 use crate::service::{ServiceDID, ServiceDocument, ServiceKey}; ··· 38 41 storage::identity_profile::model::IdentityProfile, 39 42 storage::{CachePool, StoragePool}, 40 43 }; 41 - 42 - #[cfg(feature = "embed")] 43 - pub type AppEngine = Engine<Environment<'static>>; 44 44 45 45 pub(crate) struct I18nContext { 46 46 pub(crate) supported_languages: Vec<LanguageIdentifier>,
+2 -1
src/http/handle_accept_rsvp.rs
··· 105 105 ) 106 106 .map_err(|e| anyhow::anyhow!("Failed to create remote attestation proof: {}", e))?; 107 107 108 - // Create the acceptance record 108 + // Create the acceptance record (no custom metadata for individual accepts from edit page) 109 109 let acceptance = Acceptance { 110 110 cid: content_cid.to_string(), 111 + extra: std::collections::HashMap::new(), 111 112 }; 112 113 113 114 let record_key = Tid::new().to_string();
+1 -11
src/http/handle_admin_handles.rs
··· 102 102 } 103 103 104 104 if hx_request { 105 - let hx_redirect = HxRedirect::try_from("/admin/handles"); 106 - if let Err(err) = hx_redirect { 107 - return contextual_error!( 108 - admin_ctx.web_context, 109 - admin_ctx.language, 110 - error_template, 111 - template_context! {}, 112 - err 113 - ); 114 - } 115 - let hx_redirect = hx_redirect.unwrap(); 105 + let hx_redirect = HxRedirect::from("/admin/handles"); 116 106 Ok((StatusCode::OK, hx_redirect, "").into_response()) 117 107 } else { 118 108 Ok(Redirect::to("/admin/handles").into_response())
+392
src/http/handle_bulk_accept_rsvps.rs
··· 1 + use anyhow::Result; 2 + use atproto_record::lexicon::community::lexicon::calendar::rsvp::Rsvp; 3 + use axum::{extract::State, response::IntoResponse}; 4 + use axum_extra::extract::{Cached, Form}; 5 + use http::StatusCode; 6 + use serde::Deserialize; 7 + use std::collections::HashMap; 8 + 9 + use crate::{ 10 + atproto::{ 11 + auth::{create_dpop_auth_from_aip_session, create_dpop_auth_from_oauth_session}, 12 + lexicon::acceptance::Acceptance, 13 + }, 14 + config::OAuthBackendConfig, 15 + http::{ 16 + acceptance_utils::{ 17 + format_error_html, format_success_html, send_acceptance_email_notification, 18 + verify_event_organizer_authorization, 19 + }, 20 + context::WebContext, 21 + errors::WebError, 22 + middleware_auth::Auth, 23 + middleware_i18n::Language, 24 + }, 25 + storage::event::rsvp_get, 26 + }; 27 + 28 + #[derive(Debug, Deserialize)] 29 + pub struct BulkAcceptRsvpsForm { 30 + /// Either "all_going" or a list of specific RSVP ATURIs 31 + #[serde(default)] 32 + action: String, 33 + /// List of specific RSVP ATURIs to accept (when action is not "all_going") 34 + #[serde(default)] 35 + rsvp_aturis: Vec<String>, 36 + /// Custom metadata as flattened key-value pairs (e.g., metadata_key_0, metadata_value_0) 37 + #[serde(flatten)] 38 + metadata_fields: HashMap<String, String>, 39 + } 40 + 41 + impl BulkAcceptRsvpsForm { 42 + /// Parse the flattened metadata fields into a proper HashMap 43 + fn parse_metadata(&self) -> HashMap<String, serde_json::Value> { 44 + let mut metadata = HashMap::new(); 45 + let mut keys = Vec::new(); 46 + let mut values = HashMap::new(); 47 + 48 + // Extract keys and values from the flattened form 49 + for (field_name, field_value) in &self.metadata_fields { 50 + if let Some(index_str) = field_name.strip_prefix("metadata_key_") { 51 + keys.push((index_str.to_string(), field_value.clone())); 52 + } else if let Some(index_str) = field_name.strip_prefix("metadata_value_") { 53 + values.insert(index_str.to_string(), field_value.clone()); 54 + } 55 + } 56 + 57 + // Combine keys and values into metadata HashMap 58 + for (index, key) in keys { 59 + if let Some(value) = values.get(&index) 60 + && !key.trim().is_empty() 61 + && !value.trim().is_empty() 62 + { 63 + metadata.insert(key, serde_json::Value::String(value.clone())); 64 + } 65 + } 66 + 67 + metadata 68 + } 69 + } 70 + 71 + pub(crate) async fn handle_bulk_accept_rsvps( 72 + State(web_context): State<WebContext>, 73 + Language(_language): Language, 74 + Cached(auth): Cached<Auth>, 75 + Form(form): Form<BulkAcceptRsvpsForm>, 76 + ) -> Result<impl IntoResponse, WebError> { 77 + let current_handle = auth.require("/bulk_accept_rsvps")?; 78 + 79 + // Parse metadata from form 80 + let metadata = form.parse_metadata(); 81 + 82 + // Determine which RSVPs to accept 83 + let rsvp_aturis = if form.action == "all_going" { 84 + // Get the event ATURI from the first RSVP (if provided) or error 85 + if form.rsvp_aturis.is_empty() { 86 + return Ok(( 87 + StatusCode::BAD_REQUEST, 88 + format_error_html( 89 + "Missing event information", 90 + "Cannot determine which event to process.", 91 + None, 92 + ), 93 + ) 94 + .into_response()); 95 + } 96 + 97 + // Get the first RSVP to determine the event 98 + let first_rsvp = match rsvp_get(&web_context.pool, &form.rsvp_aturis[0]).await { 99 + Ok(Some(rsvp)) => rsvp, 100 + _ => { 101 + return Ok(( 102 + StatusCode::BAD_REQUEST, 103 + format_error_html("Invalid RSVP", "Could not find the specified RSVP.", None), 104 + ) 105 + .into_response()); 106 + } 107 + }; 108 + 109 + // Get all "going" RSVPs for this event 110 + match crate::storage::event::get_event_rsvps( 111 + &web_context.pool, 112 + &first_rsvp.event_aturi, 113 + Some("going"), 114 + ) 115 + .await 116 + { 117 + Ok(rsvps) => rsvps.into_iter().map(|(aturi, _)| aturi).collect(), 118 + Err(e) => { 119 + return Ok(( 120 + StatusCode::INTERNAL_SERVER_ERROR, 121 + format_error_html( 122 + "Failed to retrieve RSVPs", 123 + "Could not fetch the event's RSVPs from the database.", 124 + Some(&e.to_string()), 125 + ), 126 + ) 127 + .into_response()); 128 + } 129 + } 130 + } else { 131 + form.rsvp_aturis 132 + }; 133 + 134 + if rsvp_aturis.is_empty() { 135 + return Ok(( 136 + StatusCode::BAD_REQUEST, 137 + format_error_html( 138 + "No RSVPs to accept", 139 + "Please select at least one RSVP to accept.", 140 + None, 141 + ), 142 + ) 143 + .into_response()); 144 + } 145 + 146 + // Track results 147 + let mut success_count = 0; 148 + let mut error_count = 0; 149 + let mut errors = Vec::new(); 150 + 151 + // Process each RSVP 152 + for rsvp_aturi in &rsvp_aturis { 153 + match process_single_acceptance( 154 + &web_context, 155 + &auth, 156 + &current_handle.did, 157 + &current_handle.pds, 158 + rsvp_aturi, 159 + &metadata, 160 + ) 161 + .await 162 + { 163 + Ok(_) => success_count += 1, 164 + Err(e) => { 165 + error_count += 1; 166 + errors.push(format!("{}: {}", rsvp_aturi, e)); 167 + } 168 + } 169 + } 170 + 171 + // Return summary 172 + let message = if error_count == 0 { 173 + format!("Successfully accepted {} RSVP(s)", success_count) 174 + } else { 175 + format!( 176 + "Accepted {} RSVP(s) with {} error(s)", 177 + success_count, error_count 178 + ) 179 + }; 180 + 181 + let details = if !errors.is_empty() { 182 + Some(errors) 183 + } else { 184 + Some(vec![ 185 + "All attendees have been notified via email.".to_string(), 186 + ]) 187 + }; 188 + 189 + Ok(( 190 + StatusCode::OK, 191 + format_success_html("Bulk Accept Complete", &message, details), 192 + ) 193 + .into_response()) 194 + } 195 + 196 + async fn process_single_acceptance( 197 + web_context: &WebContext, 198 + auth: &Auth, 199 + current_did: &str, 200 + current_pds: &str, 201 + rsvp_aturi: &str, 202 + metadata: &HashMap<String, serde_json::Value>, 203 + ) -> Result<()> { 204 + // Get the RSVP from storage 205 + let rsvp = rsvp_get(&web_context.pool, rsvp_aturi) 206 + .await? 207 + .ok_or_else(|| anyhow::anyhow!("RSVP not found"))?; 208 + 209 + let rsvp_record = serde_json::from_value::<Rsvp>(rsvp.record.0.clone()) 210 + .map_err(|_| anyhow::anyhow!("unable to deserialize rsvp record"))?; 211 + 212 + // Verify the current user is the event organizer 213 + let event = 214 + verify_event_organizer_authorization(web_context, &rsvp.event_aturi, current_did).await?; 215 + 216 + // Create DPoP auth based on OAuth backend type 217 + let dpop_auth = match (auth, &web_context.config.oauth_backend) { 218 + (Auth::Pds { session, .. }, OAuthBackendConfig::ATProtocol { .. }) => { 219 + create_dpop_auth_from_oauth_session(session)? 220 + } 221 + (Auth::Aip { access_token, .. }, OAuthBackendConfig::AIP { hostname, .. }) => { 222 + create_dpop_auth_from_aip_session(&web_context.http_client, hostname, access_token) 223 + .await? 224 + } 225 + _ => return Err(anyhow::anyhow!("Not authorized")), 226 + }; 227 + 228 + // Create or update the acceptance using the new helper 229 + let (acceptance_uri, was_updated) = 230 + crate::http::acceptance_operations::create_or_update_acceptance( 231 + web_context, 232 + dpop_auth, 233 + current_did, 234 + current_pds, 235 + &rsvp, 236 + &rsvp_record, 237 + metadata, 238 + ) 239 + .await?; 240 + 241 + // Store/update the acceptance ticket in local database 242 + let acceptance = Acceptance { 243 + cid: acceptance_uri.clone(), // Using URI as placeholder for CID 244 + extra: metadata.clone(), 245 + }; 246 + 247 + crate::http::acceptance_operations::store_acceptance_ticket( 248 + web_context, 249 + &acceptance_uri, 250 + current_did, 251 + &rsvp.did, 252 + &event.aturi, 253 + &acceptance, 254 + ) 255 + .await?; 256 + 257 + // Send email notification (async, never fails) 258 + send_acceptance_email_notification(web_context, &rsvp.did, &event.name, &event.aturi).await; 259 + 260 + if was_updated { 261 + tracing::info!("Updated existing acceptance for RSVP: {}", rsvp_aturi); 262 + } else { 263 + tracing::info!("Created new acceptance for RSVP: {}", rsvp_aturi); 264 + } 265 + 266 + Ok(()) 267 + } 268 + 269 + #[cfg(test)] 270 + mod tests { 271 + use super::*; 272 + 273 + #[test] 274 + fn test_parse_metadata_empty() { 275 + let form = BulkAcceptRsvpsForm { 276 + action: "selected".to_string(), 277 + rsvp_aturis: vec![], 278 + metadata_fields: HashMap::new(), 279 + }; 280 + 281 + let metadata = form.parse_metadata(); 282 + assert!(metadata.is_empty()); 283 + } 284 + 285 + #[test] 286 + fn test_parse_metadata_single_field() { 287 + let mut fields = HashMap::new(); 288 + fields.insert("metadata_key_0".to_string(), "table_number".to_string()); 289 + fields.insert("metadata_value_0".to_string(), "5".to_string()); 290 + 291 + let form = BulkAcceptRsvpsForm { 292 + action: "selected".to_string(), 293 + rsvp_aturis: vec![], 294 + metadata_fields: fields, 295 + }; 296 + 297 + let metadata = form.parse_metadata(); 298 + assert_eq!(metadata.len(), 1); 299 + assert_eq!( 300 + metadata.get("table_number").unwrap(), 301 + &serde_json::Value::String("5".to_string()) 302 + ); 303 + } 304 + 305 + #[test] 306 + fn test_parse_metadata_multiple_fields() { 307 + let mut fields = HashMap::new(); 308 + fields.insert("metadata_key_0".to_string(), "table_number".to_string()); 309 + fields.insert("metadata_value_0".to_string(), "5".to_string()); 310 + fields.insert("metadata_key_1".to_string(), "meal_preference".to_string()); 311 + fields.insert("metadata_value_1".to_string(), "vegetarian".to_string()); 312 + fields.insert("metadata_key_2".to_string(), "parking_pass".to_string()); 313 + fields.insert("metadata_value_2".to_string(), "A123".to_string()); 314 + 315 + let form = BulkAcceptRsvpsForm { 316 + action: "selected".to_string(), 317 + rsvp_aturis: vec![], 318 + metadata_fields: fields, 319 + }; 320 + 321 + let metadata = form.parse_metadata(); 322 + assert_eq!(metadata.len(), 3); 323 + assert_eq!( 324 + metadata.get("table_number").unwrap(), 325 + &serde_json::Value::String("5".to_string()) 326 + ); 327 + assert_eq!( 328 + metadata.get("meal_preference").unwrap(), 329 + &serde_json::Value::String("vegetarian".to_string()) 330 + ); 331 + assert_eq!( 332 + metadata.get("parking_pass").unwrap(), 333 + &serde_json::Value::String("A123".to_string()) 334 + ); 335 + } 336 + 337 + #[test] 338 + fn test_parse_metadata_ignores_empty_keys() { 339 + let mut fields = HashMap::new(); 340 + fields.insert("metadata_key_0".to_string(), "".to_string()); 341 + fields.insert("metadata_value_0".to_string(), "5".to_string()); 342 + fields.insert("metadata_key_1".to_string(), "table_number".to_string()); 343 + fields.insert("metadata_value_1".to_string(), "5".to_string()); 344 + 345 + let form = BulkAcceptRsvpsForm { 346 + action: "selected".to_string(), 347 + rsvp_aturis: vec![], 348 + metadata_fields: fields, 349 + }; 350 + 351 + let metadata = form.parse_metadata(); 352 + assert_eq!(metadata.len(), 1); 353 + assert!(metadata.contains_key("table_number")); 354 + } 355 + 356 + #[test] 357 + fn test_parse_metadata_ignores_empty_values() { 358 + let mut fields = HashMap::new(); 359 + fields.insert("metadata_key_0".to_string(), "table_number".to_string()); 360 + fields.insert("metadata_value_0".to_string(), "".to_string()); 361 + fields.insert("metadata_key_1".to_string(), "meal_preference".to_string()); 362 + fields.insert("metadata_value_1".to_string(), "vegetarian".to_string()); 363 + 364 + let form = BulkAcceptRsvpsForm { 365 + action: "selected".to_string(), 366 + rsvp_aturis: vec![], 367 + metadata_fields: fields, 368 + }; 369 + 370 + let metadata = form.parse_metadata(); 371 + assert_eq!(metadata.len(), 1); 372 + assert!(metadata.contains_key("meal_preference")); 373 + } 374 + 375 + #[test] 376 + fn test_parse_metadata_ignores_whitespace() { 377 + let mut fields = HashMap::new(); 378 + fields.insert("metadata_key_0".to_string(), " ".to_string()); 379 + fields.insert("metadata_value_0".to_string(), "5".to_string()); 380 + fields.insert("metadata_key_1".to_string(), "table_number".to_string()); 381 + fields.insert("metadata_value_1".to_string(), " ".to_string()); 382 + 383 + let form = BulkAcceptRsvpsForm { 384 + action: "selected".to_string(), 385 + rsvp_aturis: vec![], 386 + metadata_fields: fields, 387 + }; 388 + 389 + let metadata = form.parse_metadata(); 390 + assert!(metadata.is_empty()); 391 + } 392 + }
+12 -8
src/http/handle_create_event.rs
··· 31 31 use crate::http::timezones::supported_timezones; 32 32 use crate::http::utils::url_from_aturi; 33 33 use crate::select_template; 34 - use crate::storage::event::event_insert_with_metadata; 34 + use crate::storage::event::{EventInsertParams, event_insert_with_metadata}; 35 35 use crate::storage::webhook::webhook_list_enabled_by_did; 36 36 use crate::task_webhooks::TaskWork; 37 37 use atproto_client::com::atproto::repo::{ ··· 324 324 325 325 let event_insert_result = event_insert_with_metadata( 326 326 &web_context.pool, 327 - &create_record_response.uri, 328 - &create_record_response.cid, 329 - &current_handle.did, 330 - NSID, 331 - &the_record, 332 - &the_record.name, 333 - build_event_form.require_confirmed_email.unwrap_or(false), 327 + EventInsertParams { 328 + aturi: &create_record_response.uri, 329 + cid: &create_record_response.cid, 330 + did: &current_handle.did, 331 + lexicon: NSID, 332 + record: &the_record, 333 + name: &the_record.name, 334 + require_confirmed_email: build_event_form 335 + .require_confirmed_email 336 + .unwrap_or(false), 337 + }, 334 338 ) 335 339 .await; 336 340
+45 -3
src/http/handle_create_rsvp.rs
··· 27 27 utils::url_from_aturi, 28 28 }, 29 29 select_template, 30 - storage::event::{RsvpInsertParams, rsvp_insert_with_metadata}, 30 + storage::event::{RsvpInsertParams, rsvp_get_by_event_and_did, rsvp_insert_with_metadata}, 31 31 storage::webhook::webhook_list_enabled_by_did, 32 32 task_webhooks::TaskWork, 33 33 }; ··· 180 180 181 181 let record_key = crockford::encode(h.finish()); 182 182 183 + // Fetch existing RSVP from database to check if status is changing 184 + let existing_rsvp = rsvp_get_by_event_and_did( 185 + &web_context.pool, 186 + build_rsvp_form.subject_aturi.as_ref().unwrap(), 187 + &current_handle.did, 188 + ) 189 + .await 190 + .ok() 191 + .flatten(); 192 + 193 + // Determine the timestamp, signatures to use, and whether to clear validated_at 194 + let (created_at_timestamp, signatures_to_use, status_changed) = 195 + if let Some(ref existing) = existing_rsvp { 196 + // Parse existing RSVP record to get current status and signatures 197 + let existing_rsvp_record: Result<Rsvp, _> = 198 + serde_json::from_value(existing.record.0.clone()); 199 + 200 + if let Ok(existing_record) = existing_rsvp_record { 201 + // Check if status is changing 202 + let status_is_changing = existing_record.status != status; 203 + 204 + if status_is_changing { 205 + // Status changed - clear signatures, keep existing created_at, and mark status as changed 206 + (existing_record.created_at, vec![], true) 207 + } else { 208 + // Status unchanged - preserve signatures, created_at, and mark status as unchanged 209 + ( 210 + existing_record.created_at, 211 + existing_record.signatures, 212 + false, 213 + ) 214 + } 215 + } else { 216 + // Could not parse existing record - use current time, empty signatures, treat as new 217 + (now, vec![], false) 218 + } 219 + } else { 220 + // No existing RSVP - use current time, empty signatures, treat as new (not a change) 221 + (now, vec![], false) 222 + }; 223 + 183 224 let the_record = Rsvp { 184 - created_at: now, 225 + created_at: created_at_timestamp, 185 226 subject, 186 227 status, 187 - signatures: vec![], 228 + signatures: signatures_to_use, 188 229 extra: Default::default(), 189 230 }; 190 231 ··· 243 284 event_aturi: build_rsvp_form.subject_aturi.as_ref().unwrap(), 244 285 event_cid: build_rsvp_form.subject_cid.as_ref().unwrap(), 245 286 status: build_rsvp_form.status.as_ref().unwrap(), 287 + clear_validated_at: status_changed, 246 288 }, 247 289 ) 248 290 .await;
+31 -324
src/http/handle_edit_event.rs
··· 1 1 use anyhow::Result; 2 2 use axum::{extract::Path, response::IntoResponse}; 3 3 use axum_extra::extract::Form; 4 - use axum_htmx::{HxBoosted, HxRequest}; 5 - use axum_template::RenderHtml; 4 + use axum_htmx::HxRedirect; 6 5 use chrono::Utc; 7 - use http::{Method, StatusCode}; 6 + use http::StatusCode; 8 7 use minijinja::context as template_context; 9 8 10 9 use crate::atproto::auth::{ ··· 21 20 http::event_form::BuildLocationForm, 22 21 http::event_form::{BuildEventContentState, BuildEventForm, BuildStartsForm}, 23 22 http::location_edit_status::{LocationEditStatus, check_location_edit_status}, 24 - http::timezones::supported_timezones, 25 23 http::utils::url_from_aturi, 26 24 select_template, 27 25 storage::{ 28 - event::{event_get, event_update_with_metadata, get_event_rsvps_for_export}, 26 + event::{event_get, event_update_with_metadata}, 29 27 identity_profile::{handle_for_did, handle_for_handle}, 30 28 }, 31 29 }; ··· 37 35 38 36 pub(crate) async fn handle_edit_event( 39 37 ctx: UserRequestContext, 40 - method: Method, 41 - HxBoosted(hx_boosted): HxBoosted, 42 - HxRequest(hx_request): HxRequest, 43 38 Path((handle_slug, event_rkey)): Path<(String, String)>, 44 39 Form(mut build_event_form): Form<BuildEventForm>, 45 40 ) -> Result<impl IntoResponse, WebError> { ··· 48 43 let default_context = template_context! { 49 44 current_handle, 50 45 language => ctx.language.to_string(), 51 - canonical_url => format!("https://{}/{}/{}/edit", ctx.web_context.config.external_base, handle_slug, event_rkey), 52 - create_event => false, 53 - submit_url => format!("/{}/{}/edit", handle_slug, event_rkey), 54 - cancel_url => format!("/{}/{}", handle_slug, event_rkey), 55 - delete_event_url => format!("https://{}/{}/{}/delete", ctx.web_context.config.external_base, handle_slug, event_rkey), 56 46 }; 57 47 58 - let render_template = select_template!("edit_event", hx_boosted, hx_request, ctx.language); 59 - let error_template = select_template!(hx_boosted, hx_request, ctx.language); 48 + let error_template = select_template!(false, false, ctx.language); 60 49 61 50 // Lookup the event 62 51 let profile = if handle_slug.starts_with("did:") { ··· 105 94 106 95 let event = event.unwrap(); 107 96 108 - // Fetch RSVPs for the event 109 - let mut rsvps = get_event_rsvps_for_export(&ctx.web_context.pool, &lookup_aturi) 110 - .await 111 - .unwrap_or_else(|err| { 112 - tracing::warn!(?err, "Failed to fetch RSVPs for event"); 113 - Vec::new() 114 - }); 115 - 116 - // Sort RSVPs alphabetically by handle (None values go at the end) 117 - rsvps.sort_by(|a, b| match (&a.handle, &b.handle) { 118 - (Some(h_a), Some(h_b)) => h_a.to_lowercase().cmp(&h_b.to_lowercase()), 119 - (Some(_), None) => std::cmp::Ordering::Less, 120 - (None, Some(_)) => std::cmp::Ordering::Greater, 121 - (None, None) => a.did.cmp(&b.did), 122 - }); 123 - 124 97 // Check if this is a community calendar event (we only support editing those) 125 98 if event.lexicon != LexiconCommunityEventNSID { 126 99 return contextual_error!( ··· 149 122 } 150 123 }; 151 124 152 - let (default_tz, timezones) = supported_timezones(ctx.current_handle.as_ref()); 153 - 154 - let parsed_tz = default_tz 155 - .parse::<chrono_tz::Tz>() 156 - .unwrap_or(chrono_tz::UTC); 157 - 158 125 if build_event_form.build_state.is_none() { 159 126 build_event_form.build_state = Some(BuildEventContentState::default()); 160 127 } ··· 162 129 let mut starts_form = BuildStartsForm::from(build_event_form.clone()); 163 130 if starts_form.build_state.is_none() { 164 131 starts_form.build_state = Some(BuildEventContentState::default()); 165 - } 166 - 167 - if starts_form.tz.is_none() { 168 - starts_form.tz = Some(default_tz.to_string()); 169 132 } 170 133 171 134 let mut location_form = BuildLocationForm::from(build_event_form.clone()); ··· 173 136 location_form.build_state = Some(BuildEventContentState::default()); 174 137 } 175 138 176 - let is_development = cfg!(debug_assertions); 177 - 178 - // Check if event locations can be edited 179 - let location_edit_status = check_location_edit_status(&community_event.locations); 180 - 181 - // Set flags for template rendering 182 - let locations_editable = location_edit_status.is_editable(); 183 - let location_edit_reason = location_edit_status.edit_reason(); 184 - 185 - // For GET requests, populate the form with existing event data 186 - if method == Method::GET { 187 - // Extract data from the parsed community event 188 - let LexiconCommunityEvent { 189 - name, 190 - description, 191 - status, 192 - mode, 193 - starts_at, 194 - ends_at, 195 - .. 196 - } = &community_event; 197 - build_event_form.name = Some(name.clone()); 198 - build_event_form.description = Some(description.clone()); 199 - build_event_form.require_confirmed_email = Some(event.require_confirmed_email); 200 - 201 - // Load private event content if it exists 202 - if let Ok(Some(private_content)) = 203 - crate::storage::private_event_content::private_event_content_get( 204 - &ctx.web_context.pool, 205 - &lookup_aturi, 206 - ) 207 - .await 208 - { 209 - build_event_form.private_content = Some(private_content.content); 210 - 211 - // Set checkboxes based on criteria 212 - for criterion in private_content.display_criteria.0.iter() { 213 - match criterion.as_str() { 214 - "going_confirmed" => { 215 - build_event_form.private_content_criteria_going_confirmed = Some(true) 216 - } 217 - "going" => build_event_form.private_content_criteria_going = Some(true), 218 - "interested" => { 219 - build_event_form.private_content_criteria_interested = Some(true) 220 - } 221 - _ => {} 222 - } 223 - } 224 - } 225 - 226 - // If we have a single address location, populate the form fields with its data 227 - if let LocationEditStatus::Editable(Address { 228 - country, 229 - postal_code, 230 - region, 231 - locality, 232 - street, 233 - name, 234 - }) = &location_edit_status 235 - { 236 - build_event_form.location_country = Some(country.clone()); 237 - build_event_form.location_postal_code = postal_code.clone(); 238 - build_event_form.location_region = region.clone(); 239 - build_event_form.location_locality = locality.clone(); 240 - build_event_form.location_street = street.clone(); 241 - build_event_form.location_name = name.clone(); 242 - 243 - location_form.location_country = Some(country.clone()); 244 - location_form.location_postal_code = postal_code.clone(); 245 - location_form.location_region = region.clone(); 246 - location_form.location_locality = locality.clone(); 247 - location_form.location_street = street.clone(); 248 - location_form.location_name = name.clone(); 249 - } 250 - 251 - // Convert status enum to string 252 - if let Some(status_val) = status { 253 - build_event_form.status = Some( 254 - match status_val { 255 - Status::Planned => "planned", 256 - Status::Scheduled => "scheduled", 257 - Status::Cancelled => "cancelled", 258 - Status::Postponed => "postponed", 259 - Status::Rescheduled => "rescheduled", 260 - } 261 - .to_string(), 262 - ); 263 - } 264 - 265 - // Convert mode enum to string 266 - if let Some(mode_val) = mode { 267 - build_event_form.mode = Some( 268 - match mode_val { 269 - Mode::InPerson => "inperson", 270 - Mode::Virtual => "virtual", 271 - Mode::Hybrid => "hybrid", 272 - } 273 - .to_string(), 274 - ); 275 - } 276 - 277 - // Set date/time fields 278 - if let Some(start_time) = starts_at { 279 - let local_dt = start_time.with_timezone(&parsed_tz); 280 - 281 - starts_form.starts_date = Some(local_dt.format("%Y-%m-%d").to_string()); 282 - starts_form.starts_time = Some(local_dt.format("%H:%M").to_string()); 283 - starts_form.starts_at = Some(start_time.to_string()); 284 - starts_form.starts_display = Some(local_dt.format("%A, %B %-d, %Y %r %Z").to_string()); 285 - 286 - build_event_form.starts_at = starts_form.starts_at.clone(); 287 - } else { 288 - starts_form.starts_display = Some("--".to_string()); 289 - } 290 - 291 - if let Some(end_time) = ends_at { 292 - let local_dt = end_time.with_timezone(&parsed_tz); 293 - 294 - starts_form.include_ends = Some(true); 295 - starts_form.ends_date = Some(local_dt.format("%Y-%m-%d").to_string()); 296 - starts_form.ends_time = Some(local_dt.format("%H:%M").to_string()); 297 - starts_form.ends_at = Some(end_time.to_string()); 298 - starts_form.ends_display = Some(local_dt.format("%A, %B %-d, %Y %r %Z").to_string()); 299 - 300 - build_event_form.ends_at = starts_form.ends_at.clone(); 301 - } else { 302 - starts_form.ends_display = Some("--".to_string()); 303 - } 304 - 305 - build_event_form.build_state = Some(BuildEventContentState::Selected); 306 - starts_form.build_state = Some(BuildEventContentState::Selected); 307 - location_form.build_state = Some(BuildEventContentState::Selected); 308 - 309 - // Extract location information for template display 310 - let location_display_info = { 311 - let locations = &community_event.locations; 312 - if locations.is_empty() { 313 - None 314 - } else { 315 - // Format locations for display 316 - let mut formatted_locations = Vec::new(); 317 - 318 - for loc in locations { 319 - match loc { 320 - atproto_record::lexicon::community::lexicon::location::LocationOrRef::InlineAddress(typed_address) => { 321 - let Address { 322 - country, 323 - postal_code, 324 - region, 325 - locality, 326 - street, 327 - name, 328 - } = &typed_address.inner; 329 - let mut data = serde_json::Map::new(); 330 - data.insert( 331 - "type".to_string(), 332 - serde_json::Value::String("address".to_string()), 333 - ); 334 - data.insert( 335 - "country".to_string(), 336 - serde_json::Value::String(country.clone()), 337 - ); 338 - 339 - if let Some(n) = name { 340 - data.insert( 341 - "name".to_string(), 342 - serde_json::Value::String(n.clone()), 343 - ); 344 - } 345 - if let Some(s) = street { 346 - data.insert( 347 - "street".to_string(), 348 - serde_json::Value::String(s.clone()), 349 - ); 350 - } 351 - if let Some(l) = locality { 352 - data.insert( 353 - "locality".to_string(), 354 - serde_json::Value::String(l.clone()), 355 - ); 356 - } 357 - if let Some(r) = region { 358 - data.insert( 359 - "region".to_string(), 360 - serde_json::Value::String(r.clone()), 361 - ); 362 - } 363 - if let Some(pc) = postal_code { 364 - data.insert( 365 - "postal_code".to_string(), 366 - serde_json::Value::String(pc.clone()), 367 - ); 368 - } 369 - 370 - formatted_locations.push(serde_json::Value::Object(data)); 371 - } 372 - _ => { 373 - let mut data = serde_json::Map::new(); 374 - data.insert( 375 - "type".to_string(), 376 - serde_json::Value::String("unknown".to_string()), 377 - ); 378 - formatted_locations.push(serde_json::Value::Object(data)); 379 - } 380 - } 381 - } 382 - 383 - Some(formatted_locations) 384 - } 385 - }; 386 - 387 - return Ok(( 388 - StatusCode::OK, 389 - RenderHtml( 390 - &render_template, 391 - ctx.web_context.engine.clone(), 392 - template_context! { ..default_context, ..template_context! { 393 - build_event_form, 394 - starts_form, 395 - location_form, 396 - event_rkey, 397 - handle_slug, 398 - event, 399 - rsvps, 400 - timezones, 401 - is_development, 402 - locations_editable, 403 - location_edit_reason, 404 - location_display_info, 405 - }}, 406 - ), 407 - ) 408 - .into_response()); 409 - } 410 - 411 - // Process form state changes just like in create_event 139 + // Process form state changes for POST request 412 140 match build_event_form.build_state { 413 141 Some(BuildEventContentState::Reset) => { 414 142 build_event_form.build_state = Some(BuildEventContentState::Selecting); ··· 679 407 ); 680 408 } 681 409 410 + // Clear RSVP validations since event details have changed 411 + // Any previously validated RSVPs are now invalid as they were for the old event details 412 + if let Err(err) = crate::storage::acceptance::rsvp_clear_event_validations( 413 + &ctx.web_context.pool, 414 + &lookup_aturi, 415 + ) 416 + .await 417 + { 418 + tracing::error!( 419 + ?err, 420 + event_aturi = ?lookup_aturi, 421 + "Failed to clear RSVP validations after event update" 422 + ); 423 + // Don't fail the entire update if clearing validations fails 424 + // Log the error and continue 425 + } 426 + 682 427 // Save private event content if provided 683 428 let private_content = build_event_form.private_content.as_deref().unwrap_or(""); 684 429 ··· 787 532 } 788 533 } 789 534 790 - let event_url = 791 - url_from_aturi(&ctx.web_context.config.external_base, &lookup_aturi)?; 792 - 793 - return Ok(( 794 - StatusCode::OK, 795 - RenderHtml( 796 - &render_template, 797 - ctx.web_context.engine.clone(), 798 - template_context! { ..default_context, ..template_context! { 799 - build_event_form, 800 - starts_form, 801 - location_form, 802 - operation_completed => true, 803 - event_url, 804 - event_rkey, 805 - handle_slug, 806 - event, 807 - rsvps, 808 - timezones, 809 - is_development, 810 - locations_editable, 811 - location_edit_reason, 812 - }}, 813 - ), 814 - ) 815 - .into_response()); 535 + // Redirect to manage page after successful update 536 + let manage_url = format!("/{}/{}/manage?tab=details", handle_slug, event_rkey); 537 + let hx_redirect = HxRedirect::from(manage_url.as_str()); 538 + return Ok((StatusCode::OK, hx_redirect, "").into_response()); 816 539 } 817 540 } 818 541 _ => {} 819 542 } 820 543 821 - Ok(( 822 - StatusCode::OK, 823 - RenderHtml( 824 - &render_template, 825 - ctx.web_context.engine.clone(), 826 - template_context! { ..default_context, ..template_context! { 827 - build_event_form, 828 - starts_form, 829 - location_form, 830 - event_rkey, 831 - handle_slug, 832 - event, 833 - rsvps, 834 - timezones, 835 - is_development, 836 - locations_editable, 837 - location_edit_reason, 838 - }}, 839 - ), 840 - ) 841 - .into_response()) 544 + // If we reach here, validation failed or form state is unexpected 545 + // Redirect back to manage page which will handle showing the form 546 + let manage_url = format!("/{}/{}/manage?tab=details", handle_slug, event_rkey); 547 + let hx_redirect = HxRedirect::from(manage_url.as_str()); 548 + Ok((StatusCode::OK, hx_redirect, "").into_response()) 842 549 }
+4
src/http/handle_export_rsvps.rs
··· 166 166 handle: Some("user1.bsky.social".to_string()), 167 167 status: "going".to_string(), 168 168 created_at: Some(created_at), 169 + is_accepted: None, 170 + validated_at: None, 169 171 }, 170 172 RsvpExportData { 171 173 event_aturi: "at://did:example/collection/event1".to_string(), ··· 174 176 handle: None, 175 177 status: "interested".to_string(), 176 178 created_at: None, 179 + is_accepted: None, 180 + validated_at: None, 177 181 }, 178 182 ]; 179 183
+58 -18
src/http/handle_finalize_acceptance.rs
··· 20 20 middleware_i18n::Language, 21 21 }, 22 22 storage::{ 23 - acceptance::{acceptance_ticket_delete, acceptance_ticket_get}, 23 + acceptance::{ 24 + acceptance_ticket_delete_by_event_and_rsvp_did, acceptance_ticket_get, 25 + acceptance_ticket_get_by_event_and_rsvp_did, rsvp_update_validated_at, 26 + }, 24 27 event::rsvp_get_by_event_and_did, 25 28 }, 26 29 }; ··· 40 43 ) -> Result<impl IntoResponse, WebError> { 41 44 let current_handle = auth.require("/finalize_acceptance")?; 42 45 43 - // Get the acceptance ticket from storage 44 - let ticket = acceptance_ticket_get(&web_context.pool, &form.acceptance_aturi) 46 + // Get the old acceptance ticket to determine which event this is for 47 + // We need the event_aturi to look up the latest ticket (in case organizer re-accepted with new metadata) 48 + let old_ticket = acceptance_ticket_get(&web_context.pool, &form.acceptance_aturi) 45 49 .await 46 50 .map_err(|e| anyhow!("Failed to get acceptance ticket: {}", e))? 47 51 .ok_or_else(|| anyhow!("Acceptance ticket not found"))?; 48 52 49 53 // Verify the current user is the RSVP creator (recipient of the acceptance) 50 - if ticket.rsvp_did != current_handle.did { 54 + if old_ticket.rsvp_did != current_handle.did { 51 55 return Err(CommonError::NotAuthorized.into()); 52 56 } 57 + 58 + // Now fetch the LATEST acceptance ticket for this event and RSVP DID 59 + // This handles the case where the organizer re-accepted with updated metadata 60 + let ticket = acceptance_ticket_get_by_event_and_rsvp_did( 61 + &web_context.pool, 62 + &old_ticket.event_aturi, 63 + &current_handle.did, 64 + ) 65 + .await 66 + .map_err(|e| anyhow!("Failed to get latest acceptance ticket: {}", e))? 67 + .ok_or_else(|| anyhow!("Acceptance ticket not found"))?; 53 68 54 69 // Get the RSVP to verify it exists and get its aturi 55 70 let rsvp = rsvp_get_by_event_and_did(&web_context.pool, &ticket.event_aturi, &ticket.rsvp_did) ··· 103 118 }; 104 119 105 120 // Deserialize the RSVP record from storage 106 - let rsvp_record: Rsvp = serde_json::from_value(rsvp.record.0.clone()) 121 + let mut rsvp_record: Rsvp = serde_json::from_value(rsvp.record.0.clone()) 107 122 .map_err(|e| anyhow!("Failed to deserialize RSVP record: {}", e))?; 108 123 109 - // Create a base RSVP without signatures for attestation 110 - let base_rsvp = Rsvp { 111 - subject: rsvp_record.subject.clone(), 112 - status: rsvp_record.status.clone(), 113 - created_at: rsvp_record.created_at, 114 - extra: rsvp_record.extra.clone(), 115 - signatures: vec![], // Clear signatures before appending 116 - }; 117 - let typed_base_rsvp = TypedLexicon::new(base_rsvp); 124 + // Remove any existing acceptance signatures from this organizer before adding the new one 125 + // This prevents accumulation of stale signatures when an organizer re-accepts with updated metadata 126 + // We identify signatures by matching on Reference signatures and checking the DID in the AT-URI 127 + rsvp_record.signatures.retain(|sig| { 128 + use atproto_record::lexicon::community::lexicon::attestation::SignatureOrRef; 129 + match sig { 130 + SignatureOrRef::Reference(strongref) => { 131 + // Remove signatures from this organizer 132 + // URI format: at://[did]/[collection]/[rkey] 133 + // Check if the URI starts with the organizer's DID 134 + let organizer_prefix = format!("at://{}/", ticket.did); 135 + !strongref.uri.starts_with(&organizer_prefix) 136 + } 137 + SignatureOrRef::Inline(_) => { 138 + // Keep all inline signatures 139 + true 140 + } 141 + } 142 + }); 118 143 119 - // Append the remote attestation (acceptance) to the RSVP 144 + // Now use the RSVP with old organizer signatures removed 145 + // This ensures we only have one acceptance signature per organizer 146 + let typed_base_rsvp = TypedLexicon::new(rsvp_record.clone()); 147 + 148 + // Append the new remote attestation (acceptance) to the RSVP 149 + // This will add the new signature from the organizer 120 150 let updated_rsvp_record = append_remote_attestation( 121 151 typed_base_rsvp.into(), 122 152 acceptance_record.into(), // Convert to AnyInput ··· 172 202 } 173 203 }; 174 204 175 - // Delete the acceptance ticket from storage (cleanup) 176 - acceptance_ticket_delete(&web_context.pool, &ticket.aturi) 205 + // Update the RSVP validated_at timestamp to mark it as finalized 206 + rsvp_update_validated_at(&web_context.pool, &rsvp.aturi, Some(chrono::Utc::now())) 177 207 .await 178 - .map_err(|e| anyhow!("Failed to delete acceptance ticket: {}", e))?; 208 + .map_err(|e| anyhow!("Failed to update RSVP validated_at: {}", e))?; 209 + 210 + // Delete ALL acceptance tickets for this event+rsvp_did combination (cleanup) 211 + // This handles cases where there might be multiple tickets due to re-acceptance 212 + acceptance_ticket_delete_by_event_and_rsvp_did( 213 + &web_context.pool, 214 + &ticket.event_aturi, 215 + &current_handle.did, 216 + ) 217 + .await 218 + .map_err(|e| anyhow!("Failed to delete acceptance ticket: {}", e))?; 179 219 180 220 // Return success with HTMX-compatible HTML 181 221 Ok((
+510
src/http/handle_manage_event.rs
··· 1 + use anyhow::Result; 2 + use axum::{extract::Path, extract::Query, response::IntoResponse}; 3 + use axum_htmx::{HxBoosted, HxRequest}; 4 + use axum_template::RenderHtml; 5 + use http::StatusCode; 6 + use minijinja::context as template_context; 7 + use serde::Deserialize; 8 + 9 + use crate::{ 10 + contextual_error, 11 + http::context::UserRequestContext, 12 + http::errors::{CommonError, WebError}, 13 + http::event_form::{ 14 + BuildEventContentState, BuildEventForm, BuildLocationForm, BuildStartsForm, 15 + }, 16 + http::location_edit_status::check_location_edit_status, 17 + http::timezones::supported_timezones, 18 + select_template, 19 + storage::{ 20 + acceptance::acceptance_ticket_list_by_event, 21 + event::{count_event_rsvps, event_get, get_event_rsvps_for_export}, 22 + identity_profile::{handle_for_did, handle_for_handle}, 23 + }, 24 + }; 25 + use atproto_record::lexicon::community::lexicon::calendar::event::{ 26 + Event as LexiconCommunityEvent, Mode, NSID as LexiconCommunityEventNSID, Status, 27 + }; 28 + 29 + #[derive(Debug, Deserialize)] 30 + pub struct ManageEventQuery { 31 + #[serde(default = "default_tab")] 32 + tab: String, 33 + } 34 + 35 + fn default_tab() -> String { 36 + "details".to_string() 37 + } 38 + 39 + pub(crate) async fn handle_manage_event( 40 + ctx: UserRequestContext, 41 + HxBoosted(hx_boosted): HxBoosted, 42 + HxRequest(hx_request): HxRequest, 43 + Path((handle_slug, event_rkey)): Path<(String, String)>, 44 + Query(query): Query<ManageEventQuery>, 45 + ) -> Result<impl IntoResponse, WebError> { 46 + let current_handle = ctx 47 + .auth 48 + .require(&format!("/{}/{}/manage", handle_slug, event_rkey))?; 49 + 50 + let default_context = template_context! { 51 + current_handle, 52 + language => ctx.language.to_string(), 53 + canonical_url => format!("https://{}/{}/{}/manage", ctx.web_context.config.external_base, handle_slug, event_rkey), 54 + }; 55 + 56 + let error_template = select_template!(hx_boosted, hx_request, ctx.language); 57 + 58 + // Lookup the event 59 + let profile = if handle_slug.starts_with("did:") { 60 + handle_for_did(&ctx.web_context.pool, &handle_slug) 61 + .await 62 + .map_err(WebError::from) 63 + } else { 64 + let handle = if let Some(handle) = handle_slug.strip_prefix('@') { 65 + handle 66 + } else { 67 + &handle_slug 68 + }; 69 + handle_for_handle(&ctx.web_context.pool, handle) 70 + .await 71 + .map_err(WebError::from) 72 + }?; 73 + 74 + let lookup_aturi = format!( 75 + "at://{}/{}/{}", 76 + profile.did, LexiconCommunityEventNSID, event_rkey 77 + ); 78 + 79 + // Check if the user is authorized to manage this event (must be the creator) 80 + if profile.did != current_handle.did { 81 + return contextual_error!( 82 + ctx.web_context, 83 + ctx.language, 84 + error_template, 85 + default_context, 86 + CommonError::NotAuthorized, 87 + StatusCode::FORBIDDEN 88 + ); 89 + } 90 + 91 + let event = event_get(&ctx.web_context.pool, &lookup_aturi).await; 92 + if let Err(err) = event { 93 + return contextual_error!( 94 + ctx.web_context, 95 + ctx.language, 96 + error_template, 97 + default_context, 98 + err, 99 + StatusCode::NOT_FOUND 100 + ); 101 + } 102 + 103 + let event = event.unwrap(); 104 + 105 + // Fetch RSVP counts for all statuses 106 + let going_count = count_event_rsvps(&ctx.web_context.pool, &lookup_aturi, "going") 107 + .await 108 + .unwrap_or_default(); 109 + let interested_count = count_event_rsvps(&ctx.web_context.pool, &lookup_aturi, "interested") 110 + .await 111 + .unwrap_or_default(); 112 + let notgoing_count = count_event_rsvps(&ctx.web_context.pool, &lookup_aturi, "notgoing") 113 + .await 114 + .unwrap_or_default(); 115 + 116 + // Create a simple struct to hold counts for template 117 + #[derive(serde::Serialize)] 118 + struct RsvpCounts { 119 + going: u32, 120 + interested: u32, 121 + notgoing: u32, 122 + } 123 + 124 + let rsvp_counts = RsvpCounts { 125 + going: going_count, 126 + interested: interested_count, 127 + notgoing: notgoing_count, 128 + }; 129 + 130 + // Fetch RSVPs for the event (for attendees and export tabs) 131 + let mut rsvps = get_event_rsvps_for_export(&ctx.web_context.pool, &lookup_aturi) 132 + .await 133 + .unwrap_or_else(|err| { 134 + tracing::warn!(?err, "Failed to fetch RSVPs for event"); 135 + Vec::new() 136 + }); 137 + 138 + let delete_event_url = format!( 139 + "https://{}/{}/{}/delete", 140 + ctx.web_context.config.external_base, handle_slug, event_rkey 141 + ); 142 + 143 + let is_development = cfg!(debug_assertions); 144 + 145 + // Determine which tab template to use 146 + let (tab_template, active_tab) = match query.tab.as_str() { 147 + "attendees" => ("en-us/manage_event_attendees_tab.html", "attendees"), 148 + "content" => ("en-us/manage_event_content_tab.html", "content"), 149 + "export" => ("en-us/manage_event_export_tab.html", "export"), 150 + "settings" => ("en-us/manage_event_settings_tab.html", "settings"), 151 + _ => ("en-us/manage_event_details_tab.html", "details"), 152 + }; 153 + 154 + // For attendees tab, enrich RSVPs with acceptance data 155 + let accepted_count = if active_tab == "attendees" { 156 + // Fetch acceptance tickets for this event 157 + let acceptance_tickets = 158 + acceptance_ticket_list_by_event(&ctx.web_context.pool, &lookup_aturi) 159 + .await 160 + .unwrap_or_else(|err| { 161 + tracing::warn!(?err, "Failed to fetch acceptance tickets for event"); 162 + Vec::new() 163 + }); 164 + 165 + // Create a map of rsvp_did -> acceptance ticket for quick lookup 166 + let acceptance_map: std::collections::HashMap<String, _> = acceptance_tickets 167 + .into_iter() 168 + .map(|ticket| (ticket.rsvp_did.clone(), ticket)) 169 + .collect(); 170 + 171 + // Enrich RSVPs with acceptance status 172 + // An RSVP is considered "accepted" if either: 173 + // 1. An acceptance ticket exists (organizer accepted), OR 174 + // 2. The RSVP has been validated (attendee finalized acceptance) 175 + for rsvp in &mut rsvps { 176 + if acceptance_map.contains_key(&rsvp.did) || rsvp.validated_at.is_some() { 177 + rsvp.is_accepted = Some(true); 178 + } 179 + } 180 + 181 + // Sort RSVPs alphabetically by handle (None values go at the end) 182 + rsvps.sort_by(|a, b| match (&a.handle, &b.handle) { 183 + (Some(h_a), Some(h_b)) => h_a.to_lowercase().cmp(&h_b.to_lowercase()), 184 + (Some(_), None) => std::cmp::Ordering::Less, 185 + (None, Some(_)) => std::cmp::Ordering::Greater, 186 + (None, None) => a.did.cmp(&b.did), 187 + }); 188 + 189 + // Count accepted RSVPs 190 + rsvps.iter().filter(|r| r.is_accepted == Some(true)).count() 191 + } else { 192 + 0 193 + }; 194 + 195 + // Prepare form data for details and content tabs 196 + let mut build_event_form = BuildEventForm { 197 + build_state: None, 198 + name: None, 199 + name_error: None, 200 + description: None, 201 + description_error: None, 202 + status: None, 203 + status_error: None, 204 + starts_at: None, 205 + starts_at_error: None, 206 + ends_at: None, 207 + ends_at_error: None, 208 + mode: None, 209 + mode_error: None, 210 + location_country: None, 211 + location_country_error: None, 212 + location_street: None, 213 + location_street_error: None, 214 + location_locality: None, 215 + location_locality_error: None, 216 + location_region: None, 217 + location_region_error: None, 218 + location_postal_code: None, 219 + location_postal_code_error: None, 220 + location_name: None, 221 + location_name_error: None, 222 + require_confirmed_email: None, 223 + send_notifications: None, 224 + private_content: None, 225 + private_content_criteria_going_confirmed: None, 226 + private_content_criteria_going: None, 227 + private_content_criteria_interested: None, 228 + }; 229 + 230 + // Initialize timezone and form helpers (needed for details tab) 231 + let (default_tz, timezones) = supported_timezones(ctx.current_handle.as_ref()); 232 + let parsed_tz = default_tz 233 + .parse::<chrono_tz::Tz>() 234 + .unwrap_or(chrono_tz::UTC); 235 + 236 + // Load event data for the details and content tabs 237 + let (starts_form, location_form, locations_editable, location_edit_reason) = if active_tab 238 + == "details" 239 + || active_tab == "content" 240 + { 241 + let community_event = 242 + match serde_json::from_value::<LexiconCommunityEvent>(event.record.0.clone()) { 243 + Ok(event) => event, 244 + Err(_) => { 245 + return contextual_error!( 246 + ctx.web_context, 247 + ctx.language, 248 + error_template, 249 + default_context, 250 + CommonError::InvalidEventFormat, 251 + StatusCode::BAD_REQUEST 252 + ); 253 + } 254 + }; 255 + 256 + // Populate form with event data 257 + let LexiconCommunityEvent { 258 + name, 259 + description, 260 + status, 261 + mode, 262 + starts_at, 263 + ends_at, 264 + .. 265 + } = &community_event; 266 + 267 + build_event_form.name = Some(name.clone()); 268 + build_event_form.description = Some(description.clone()); 269 + build_event_form.require_confirmed_email = Some(event.require_confirmed_email); 270 + 271 + // Convert status enum to string 272 + if let Some(status_val) = status { 273 + build_event_form.status = Some( 274 + match status_val { 275 + Status::Planned => "planned", 276 + Status::Scheduled => "scheduled", 277 + Status::Cancelled => "cancelled", 278 + Status::Postponed => "postponed", 279 + Status::Rescheduled => "rescheduled", 280 + } 281 + .to_string(), 282 + ); 283 + } 284 + 285 + // Convert mode enum to string 286 + if let Some(mode_val) = mode { 287 + build_event_form.mode = Some( 288 + match mode_val { 289 + Mode::InPerson => "inperson", 290 + Mode::Virtual => "virtual", 291 + Mode::Hybrid => "hybrid", 292 + } 293 + .to_string(), 294 + ); 295 + } 296 + 297 + // Initialize starts_form 298 + let mut starts_form = BuildStartsForm::from(build_event_form.clone()); 299 + starts_form.build_state = Some(BuildEventContentState::Selected); 300 + if starts_form.tz.is_none() { 301 + starts_form.tz = Some(default_tz.to_string()); 302 + } 303 + 304 + // Set date/time fields 305 + if let Some(start_time) = starts_at { 306 + let local_dt = start_time.with_timezone(&parsed_tz); 307 + starts_form.starts_date = Some(local_dt.format("%Y-%m-%d").to_string()); 308 + starts_form.starts_time = Some(local_dt.format("%H:%M").to_string()); 309 + starts_form.starts_at = Some(start_time.to_string()); 310 + starts_form.starts_display = Some(local_dt.format("%A, %B %-d, %Y %r %Z").to_string()); 311 + build_event_form.starts_at = starts_form.starts_at.clone(); 312 + } else { 313 + starts_form.starts_display = Some("--".to_string()); 314 + } 315 + 316 + if let Some(end_time) = ends_at { 317 + let local_dt = end_time.with_timezone(&parsed_tz); 318 + starts_form.include_ends = Some(true); 319 + starts_form.ends_date = Some(local_dt.format("%Y-%m-%d").to_string()); 320 + starts_form.ends_time = Some(local_dt.format("%H:%M").to_string()); 321 + starts_form.ends_at = Some(end_time.to_string()); 322 + starts_form.ends_display = Some(local_dt.format("%A, %B %-d, %Y %r %Z").to_string()); 323 + build_event_form.ends_at = starts_form.ends_at.clone(); 324 + } else { 325 + starts_form.ends_display = Some("--".to_string()); 326 + } 327 + 328 + // Initialize location_form 329 + let mut location_form = BuildLocationForm::from(build_event_form.clone()); 330 + location_form.build_state = Some(BuildEventContentState::Selected); 331 + 332 + // Check location edit status 333 + let location_edit_status = check_location_edit_status(&community_event.locations); 334 + let locations_editable = location_edit_status.is_editable(); 335 + let location_edit_reason = location_edit_status.edit_reason(); 336 + 337 + // If we have a single editable address location, populate the form fields 338 + if let crate::http::location_edit_status::LocationEditStatus::Editable(address) = 339 + &location_edit_status 340 + { 341 + build_event_form.location_country = Some(address.country.clone()); 342 + build_event_form.location_postal_code = address.postal_code.clone(); 343 + build_event_form.location_region = address.region.clone(); 344 + build_event_form.location_locality = address.locality.clone(); 345 + build_event_form.location_street = address.street.clone(); 346 + build_event_form.location_name = address.name.clone(); 347 + 348 + location_form.location_country = Some(address.country.clone()); 349 + location_form.location_postal_code = address.postal_code.clone(); 350 + location_form.location_region = address.region.clone(); 351 + location_form.location_locality = address.locality.clone(); 352 + location_form.location_street = address.street.clone(); 353 + location_form.location_name = address.name.clone(); 354 + } 355 + 356 + build_event_form.build_state = Some(BuildEventContentState::Selected); 357 + 358 + // Load private event content if it exists 359 + if let Ok(Some(private_content)) = 360 + crate::storage::private_event_content::private_event_content_get( 361 + &ctx.web_context.pool, 362 + &lookup_aturi, 363 + ) 364 + .await 365 + { 366 + build_event_form.private_content = Some(private_content.content); 367 + 368 + // Set checkboxes based on criteria 369 + for criterion in private_content.display_criteria.0.iter() { 370 + match criterion.as_str() { 371 + "going_confirmed" => { 372 + build_event_form.private_content_criteria_going_confirmed = Some(true) 373 + } 374 + "going" => build_event_form.private_content_criteria_going = Some(true), 375 + "interested" => { 376 + build_event_form.private_content_criteria_interested = Some(true) 377 + } 378 + _ => {} 379 + } 380 + } 381 + } 382 + 383 + ( 384 + starts_form, 385 + location_form, 386 + locations_editable, 387 + location_edit_reason, 388 + ) 389 + } else { 390 + // For other tabs, use default/empty forms 391 + let starts_form = BuildStartsForm { 392 + build_state: Some(BuildEventContentState::default()), 393 + tz: Some(default_tz.to_string()), 394 + tz_error: None, 395 + starts_date: None, 396 + starts_date_error: None, 397 + starts_time: None, 398 + starts_time_error: None, 399 + ends_date: None, 400 + ends_date_error: None, 401 + ends_time: None, 402 + ends_time_error: None, 403 + include_ends: None, 404 + starts_at: None, 405 + ends_at: None, 406 + starts_at_error: None, 407 + ends_at_error: None, 408 + starts_display: None, 409 + ends_display: None, 410 + }; 411 + 412 + let location_form = BuildLocationForm { 413 + build_state: Some(BuildEventContentState::default()), 414 + location_country: None, 415 + location_country_error: None, 416 + location_street: None, 417 + location_street_error: None, 418 + location_locality: None, 419 + location_locality_error: None, 420 + location_region: None, 421 + location_region_error: None, 422 + location_postal_code: None, 423 + location_postal_code_error: None, 424 + location_name: None, 425 + location_name_error: None, 426 + }; 427 + 428 + (starts_form, location_form, true, None) 429 + }; 430 + 431 + // Common template variables needed by create_event.partial.html 432 + let submit_url = format!("/{}/{}/edit", handle_slug, event_rkey); 433 + let cancel_url = format!("/{}/{}/manage", handle_slug, event_rkey); 434 + let create_event = false; 435 + 436 + // If this is an HTMX request, render tabs + content together for active state updates 437 + if hx_request { 438 + return Ok(( 439 + StatusCode::OK, 440 + RenderHtml( 441 + "en-us/manage_event_tabs_and_content.partial.html", 442 + ctx.web_context.engine.clone(), 443 + template_context! { 444 + current_handle => ctx.current_handle, 445 + language => ctx.language.to_string(), 446 + handle_slug, 447 + event_rkey, 448 + event, 449 + rsvps, 450 + rsvp_counts, 451 + accepted_count, 452 + going_count, 453 + interested_count, 454 + build_event_form, 455 + starts_form, 456 + location_form, 457 + timezones, 458 + locations_editable, 459 + location_edit_reason, 460 + delete_event_url, 461 + is_development, 462 + active_tab, 463 + tab_template, 464 + submit_url, 465 + cancel_url, 466 + create_event, 467 + }, 468 + ), 469 + ) 470 + .into_response()); 471 + } 472 + 473 + // Render full page with selected tab 474 + let render_template = select_template!("manage_event", hx_boosted, hx_request, ctx.language); 475 + 476 + Ok(( 477 + StatusCode::OK, 478 + RenderHtml( 479 + &render_template, 480 + ctx.web_context.engine.clone(), 481 + template_context! { 482 + current_handle => ctx.current_handle, 483 + language => ctx.language.to_string(), 484 + canonical_url => format!("https://{}/{}/{}/manage", ctx.web_context.config.external_base, handle_slug, event_rkey), 485 + handle_slug, 486 + event_rkey, 487 + event, 488 + rsvps, 489 + rsvp_counts, 490 + accepted_count, 491 + going_count, 492 + interested_count, 493 + build_event_form, 494 + starts_form, 495 + location_form, 496 + timezones, 497 + locations_editable, 498 + location_edit_reason, 499 + delete_event_url, 500 + is_development, 501 + active_tab, 502 + tab_template, 503 + submit_url, 504 + cancel_url, 505 + create_event, 506 + }, 507 + ), 508 + ) 509 + .into_response()) 510 + }
+294
src/http/handle_manage_event_content.rs
··· 1 + use anyhow::Result; 2 + use axum::{extract::Path, response::IntoResponse}; 3 + use axum_extra::extract::Form; 4 + use axum_template::RenderHtml; 5 + use http::StatusCode; 6 + use minijinja::context as template_context; 7 + use serde::Deserialize; 8 + 9 + use crate::{ 10 + contextual_error, 11 + http::context::UserRequestContext, 12 + http::errors::{CommonError, WebError}, 13 + select_template, 14 + storage::{ 15 + event::{count_event_rsvps, event_get, get_event_rsvps_for_export}, 16 + identity_profile::{handle_for_did, handle_for_handle}, 17 + }, 18 + }; 19 + use atproto_record::lexicon::community::lexicon::calendar::event::NSID as LexiconCommunityEventNSID; 20 + 21 + #[derive(Debug, Deserialize)] 22 + pub struct PrivateContentForm { 23 + private_content: Option<String>, 24 + private_content_criteria_going_confirmed: Option<bool>, 25 + private_content_criteria_going: Option<bool>, 26 + private_content_criteria_interested: Option<bool>, 27 + } 28 + 29 + pub(crate) async fn handle_manage_event_content_save( 30 + ctx: UserRequestContext, 31 + Path((handle_slug, event_rkey)): Path<(String, String)>, 32 + Form(form): Form<PrivateContentForm>, 33 + ) -> Result<impl IntoResponse, WebError> { 34 + let current_handle = ctx 35 + .auth 36 + .require(&format!("/{}/{}/manage/content", handle_slug, event_rkey))?; 37 + 38 + let default_context = template_context! { 39 + current_handle, 40 + language => ctx.language.to_string(), 41 + }; 42 + 43 + let error_template = select_template!(false, false, ctx.language); 44 + 45 + // Lookup the event 46 + let profile = if handle_slug.starts_with("did:") { 47 + handle_for_did(&ctx.web_context.pool, &handle_slug) 48 + .await 49 + .map_err(WebError::from) 50 + } else { 51 + let handle = if let Some(handle) = handle_slug.strip_prefix('@') { 52 + handle 53 + } else { 54 + &handle_slug 55 + }; 56 + handle_for_handle(&ctx.web_context.pool, handle) 57 + .await 58 + .map_err(WebError::from) 59 + }?; 60 + 61 + let lookup_aturi = format!( 62 + "at://{}/{}/{}", 63 + profile.did, LexiconCommunityEventNSID, event_rkey 64 + ); 65 + 66 + // Check if the user is authorized to manage this event (must be the creator) 67 + if profile.did != current_handle.did { 68 + return contextual_error!( 69 + ctx.web_context, 70 + ctx.language, 71 + error_template, 72 + default_context, 73 + CommonError::NotAuthorized, 74 + StatusCode::FORBIDDEN 75 + ); 76 + } 77 + 78 + let event = event_get(&ctx.web_context.pool, &lookup_aturi).await; 79 + if let Err(err) = event { 80 + return contextual_error!( 81 + ctx.web_context, 82 + ctx.language, 83 + error_template, 84 + default_context, 85 + err, 86 + StatusCode::NOT_FOUND 87 + ); 88 + } 89 + 90 + let event = event.unwrap(); 91 + 92 + // Build display criteria array from checkboxes 93 + let mut display_criteria = Vec::new(); 94 + if form 95 + .private_content_criteria_going_confirmed 96 + .unwrap_or(false) 97 + { 98 + display_criteria.push("going_confirmed".to_string()); 99 + } 100 + if form.private_content_criteria_going.unwrap_or(false) { 101 + display_criteria.push("going".to_string()); 102 + } 103 + if form.private_content_criteria_interested.unwrap_or(false) { 104 + display_criteria.push("interested".to_string()); 105 + } 106 + 107 + let private_content = form.private_content.as_deref().unwrap_or(""); 108 + 109 + // Only save if there's content or criteria, otherwise delete 110 + if !private_content.is_empty() || !display_criteria.is_empty() { 111 + if let Err(err) = crate::storage::private_event_content::private_event_content_upsert( 112 + &ctx.web_context.pool, 113 + &lookup_aturi, 114 + &display_criteria, 115 + private_content, 116 + ) 117 + .await 118 + { 119 + tracing::error!("Failed to save private event content: {:?}", err); 120 + return contextual_error!( 121 + ctx.web_context, 122 + ctx.language, 123 + error_template, 124 + default_context, 125 + err, 126 + StatusCode::INTERNAL_SERVER_ERROR 127 + ); 128 + } 129 + } else { 130 + // Delete private content if both content and criteria are empty 131 + if let Err(err) = crate::storage::private_event_content::private_event_content_delete( 132 + &ctx.web_context.pool, 133 + &lookup_aturi, 134 + ) 135 + .await 136 + { 137 + tracing::error!("Failed to delete private event content: {:?}", err); 138 + } 139 + } 140 + 141 + // Fetch data needed for rendering 142 + let going_count = count_event_rsvps(&ctx.web_context.pool, &lookup_aturi, "going") 143 + .await 144 + .unwrap_or_default(); 145 + let interested_count = count_event_rsvps(&ctx.web_context.pool, &lookup_aturi, "interested") 146 + .await 147 + .unwrap_or_default(); 148 + let notgoing_count = count_event_rsvps(&ctx.web_context.pool, &lookup_aturi, "notgoing") 149 + .await 150 + .unwrap_or_default(); 151 + 152 + #[derive(serde::Serialize)] 153 + struct RsvpCounts { 154 + going: u32, 155 + interested: u32, 156 + notgoing: u32, 157 + } 158 + 159 + let rsvp_counts = RsvpCounts { 160 + going: going_count, 161 + interested: interested_count, 162 + notgoing: notgoing_count, 163 + }; 164 + 165 + let rsvps = get_event_rsvps_for_export(&ctx.web_context.pool, &lookup_aturi) 166 + .await 167 + .unwrap_or_default(); 168 + 169 + let delete_event_url = format!( 170 + "https://{}/{}/{}/delete", 171 + ctx.web_context.config.external_base, handle_slug, event_rkey 172 + ); 173 + 174 + let is_development = cfg!(debug_assertions); 175 + 176 + // Prepare updated form data 177 + let mut build_event_form = crate::http::event_form::BuildEventForm { 178 + build_state: None, 179 + name: None, 180 + name_error: None, 181 + description: None, 182 + description_error: None, 183 + status: None, 184 + status_error: None, 185 + starts_at: None, 186 + starts_at_error: None, 187 + ends_at: None, 188 + ends_at_error: None, 189 + mode: None, 190 + mode_error: None, 191 + location_country: None, 192 + location_country_error: None, 193 + location_street: None, 194 + location_street_error: None, 195 + location_locality: None, 196 + location_locality_error: None, 197 + location_region: None, 198 + location_region_error: None, 199 + location_postal_code: None, 200 + location_postal_code_error: None, 201 + location_name: None, 202 + location_name_error: None, 203 + require_confirmed_email: None, 204 + send_notifications: None, 205 + private_content: None, 206 + private_content_criteria_going_confirmed: None, 207 + private_content_criteria_going: None, 208 + private_content_criteria_interested: None, 209 + }; 210 + build_event_form.private_content = form.private_content.clone(); 211 + build_event_form.private_content_criteria_going_confirmed = Some( 212 + form.private_content_criteria_going_confirmed 213 + .unwrap_or(false), 214 + ); 215 + build_event_form.private_content_criteria_going = 216 + Some(form.private_content_criteria_going.unwrap_or(false)); 217 + build_event_form.private_content_criteria_interested = 218 + Some(form.private_content_criteria_interested.unwrap_or(false)); 219 + 220 + // Prepare minimal form helpers for template consistency 221 + let starts_form = crate::http::event_form::BuildStartsForm { 222 + build_state: None, 223 + tz: None, 224 + tz_error: None, 225 + starts_date: None, 226 + starts_date_error: None, 227 + starts_time: None, 228 + starts_time_error: None, 229 + ends_date: None, 230 + ends_date_error: None, 231 + ends_time: None, 232 + ends_time_error: None, 233 + include_ends: None, 234 + starts_at: None, 235 + ends_at: None, 236 + starts_at_error: None, 237 + ends_at_error: None, 238 + starts_display: None, 239 + ends_display: None, 240 + }; 241 + 242 + let location_form = crate::http::event_form::BuildLocationForm { 243 + build_state: None, 244 + location_country: None, 245 + location_country_error: None, 246 + location_street: None, 247 + location_street_error: None, 248 + location_locality: None, 249 + location_locality_error: None, 250 + location_region: None, 251 + location_region_error: None, 252 + location_postal_code: None, 253 + location_postal_code_error: None, 254 + location_name: None, 255 + location_name_error: None, 256 + }; 257 + 258 + let timezones = Vec::<String>::new(); 259 + let submit_url = format!("/{}/{}/edit", handle_slug, event_rkey); 260 + let cancel_url = format!("/{}/{}/manage", handle_slug, event_rkey); 261 + 262 + // Render tabs + content together with success message 263 + Ok(( 264 + StatusCode::OK, 265 + RenderHtml( 266 + "en-us/manage_event_tabs_and_content.partial.html", 267 + ctx.web_context.engine.clone(), 268 + template_context! { 269 + current_handle => ctx.current_handle, 270 + language => ctx.language.to_string(), 271 + handle_slug, 272 + event_rkey, 273 + event, 274 + rsvps, 275 + rsvp_counts, 276 + build_event_form, 277 + starts_form, 278 + location_form, 279 + timezones, 280 + locations_editable => true, 281 + location_edit_reason => None::<String>, 282 + delete_event_url, 283 + is_development, 284 + active_tab => "content", 285 + tab_template => "en-us/manage_event_content_tab.html", 286 + submit_url, 287 + cancel_url, 288 + create_event => false, 289 + content_saved => true, 290 + }, 291 + ), 292 + ) 293 + .into_response()) 294 + }
+1 -2
src/http/handle_oauth_aip_login.rs
··· 219 219 ); 220 220 221 221 if hx_request { 222 - let hx_redirect = HxRedirect::try_from(destination.as_str()) 223 - .expect("HxRedirect construction should not fail"); 222 + let hx_redirect = HxRedirect::from(destination.as_str()); 224 223 return Ok((StatusCode::OK, hx_redirect, "").into_response()); 225 224 } 226 225
+1 -13
src/http/handle_oauth_login.rs
··· 346 346 stringify(oauth_args) 347 347 ); 348 348 349 - let hx_redirect = match HxRedirect::try_from(destination.as_str()) { 350 - Ok(value) => value, 351 - Err(err) => { 352 - return contextual_error!( 353 - web_context, 354 - language, 355 - error_template, 356 - default_context, 357 - err 358 - ); 359 - } 360 - }; 361 - 349 + let hx_redirect = HxRedirect::from(destination.as_str()); 362 350 return Ok((StatusCode::OK, hx_redirect, "").into_response()); 363 351 } 364 352
+139
src/http/handle_unaccept_rsvp.rs
··· 1 + use anyhow::Result; 2 + use axum::{extract::State, response::IntoResponse}; 3 + use axum_extra::extract::{Cached, Form}; 4 + use http::StatusCode; 5 + use serde::Deserialize; 6 + 7 + use crate::{ 8 + atproto::auth::{create_dpop_auth_from_aip_session, create_dpop_auth_from_oauth_session}, 9 + config::OAuthBackendConfig, 10 + http::{ 11 + acceptance_utils::{ 12 + format_error_html, format_success_html, verify_event_organizer_authorization, 13 + }, 14 + context::WebContext, 15 + errors::WebError, 16 + middleware_auth::Auth, 17 + middleware_i18n::Language, 18 + }, 19 + storage::event::rsvp_get, 20 + }; 21 + 22 + #[derive(Debug, Deserialize)] 23 + pub struct UnacceptRsvpForm { 24 + rsvp_aturi: String, 25 + } 26 + 27 + pub(crate) async fn handle_unaccept_rsvp( 28 + State(web_context): State<WebContext>, 29 + Language(_language): Language, 30 + Cached(auth): Cached<Auth>, 31 + Form(form): Form<UnacceptRsvpForm>, 32 + ) -> Result<impl IntoResponse, WebError> { 33 + let current_handle = auth.require("/unaccept_rsvp")?; 34 + 35 + // Get the RSVP from storage 36 + let rsvp = match rsvp_get(&web_context.pool, &form.rsvp_aturi).await { 37 + Ok(Some(rsvp)) => rsvp, 38 + Ok(None) => { 39 + return Ok(( 40 + StatusCode::NOT_FOUND, 41 + format_error_html("RSVP not found", "The specified RSVP does not exist.", None), 42 + ) 43 + .into_response()); 44 + } 45 + Err(e) => { 46 + return Ok(( 47 + StatusCode::INTERNAL_SERVER_ERROR, 48 + format_error_html( 49 + "Database error", 50 + "Failed to retrieve RSVP from database.", 51 + Some(&e.to_string()), 52 + ), 53 + ) 54 + .into_response()); 55 + } 56 + }; 57 + 58 + // Verify the current user is the event organizer 59 + let event = match verify_event_organizer_authorization( 60 + &web_context, 61 + &rsvp.event_aturi, 62 + &current_handle.did, 63 + ) 64 + .await 65 + { 66 + Ok(event) => event, 67 + Err(e) => { 68 + return Ok(( 69 + StatusCode::FORBIDDEN, 70 + format_error_html( 71 + "Not authorized", 72 + "You must be the event organizer to unaccept RSVPs.", 73 + Some(&e.to_string()), 74 + ), 75 + ) 76 + .into_response()); 77 + } 78 + }; 79 + 80 + // Create DPoP auth based on OAuth backend type 81 + let dpop_auth = match (&auth, &web_context.config.oauth_backend) { 82 + (Auth::Pds { session, .. }, OAuthBackendConfig::ATProtocol { .. }) => { 83 + create_dpop_auth_from_oauth_session(session)? 84 + } 85 + (Auth::Aip { access_token, .. }, OAuthBackendConfig::AIP { hostname, .. }) => { 86 + create_dpop_auth_from_aip_session(&web_context.http_client, hostname, access_token) 87 + .await? 88 + } 89 + _ => { 90 + return Ok(( 91 + StatusCode::UNAUTHORIZED, 92 + format_error_html( 93 + "Not authorized", 94 + "You must be logged in to unaccept RSVPs.", 95 + None, 96 + ), 97 + ) 98 + .into_response()); 99 + } 100 + }; 101 + 102 + // Delete the acceptance 103 + match crate::http::acceptance_operations::delete_acceptance( 104 + &web_context, 105 + dpop_auth, 106 + &current_handle.did, 107 + &current_handle.pds, 108 + &rsvp.aturi, 109 + &event.aturi, 110 + &rsvp.did, 111 + ) 112 + .await 113 + { 114 + Ok(_) => { 115 + tracing::info!("Unaccepted RSVP: {}", form.rsvp_aturi); 116 + Ok(( 117 + StatusCode::OK, 118 + format_success_html( 119 + "RSVP Unaccepted", 120 + &format!("Successfully unaccepted RSVP for {}", rsvp.did), 121 + Some(vec!["The acceptance record has been removed.".to_string()]), 122 + ), 123 + ) 124 + .into_response()) 125 + } 126 + Err(e) => { 127 + tracing::error!("Failed to unaccept RSVP: {}", e); 128 + Ok(( 129 + StatusCode::INTERNAL_SERVER_ERROR, 130 + format_error_html( 131 + "Failed to unaccept", 132 + "Could not remove the acceptance record.", 133 + Some(&e.to_string()), 134 + ), 135 + ) 136 + .into_response()) 137 + } 138 + } 139 + }
+172 -19
src/http/handle_view_event.rs
··· 22 22 use crate::http::utils::url_from_aturi; 23 23 use crate::select_template; 24 24 use crate::storage::StoragePool; 25 - use crate::storage::acceptance::acceptance_ticket_get_by_event_and_rsvp_did; 25 + use crate::storage::acceptance::{ 26 + acceptance_record_get, acceptance_ticket_get_by_event_and_rsvp_did, 27 + }; 26 28 use crate::storage::event::RsvpDisplayData; 27 29 use crate::storage::event::count_event_rsvps; 28 30 use crate::storage::event::event_exists; 29 31 use crate::storage::event::event_get; 30 32 use crate::storage::event::get_event_rsvps_with_validation; 31 33 use crate::storage::event::get_user_rsvp_status_and_validation; 34 + use crate::storage::event::rsvp_get_by_event_and_did; 32 35 use crate::storage::identity_profile::handle_for_did; 33 36 use crate::storage::identity_profile::handle_for_handle; 34 37 use crate::storage::identity_profile::handles_by_did; 35 38 use crate::storage::identity_profile::model::IdentityProfile; 39 + use atproto_record::lexicon::community::lexicon::attestation::SignatureOrRef; 36 40 use atproto_record::lexicon::community::lexicon::calendar::event::NSID; 41 + use atproto_record::lexicon::community::lexicon::calendar::rsvp::Rsvp; 37 42 38 43 #[derive(Debug, Deserialize, Serialize, PartialEq)] 39 44 pub enum RSVPTab { ··· 81 86 tracing::warn!("Failed to fetch organizer handle: {}", err); 82 87 None 83 88 } 89 + } 90 + } 91 + 92 + /// Helper function to extract acceptance metadata from a validated RSVP 93 + /// Returns None if the RSVP is not validated or has no acceptance metadata 94 + async fn fetch_validated_acceptance_metadata( 95 + pool: &StoragePool, 96 + event_aturi: &str, 97 + user_did: &str, 98 + organizer_did: &str, 99 + ) -> Option<Vec<(String, String)>> { 100 + // Get the user's RSVP record 101 + let rsvp = match rsvp_get_by_event_and_did(pool, event_aturi, user_did).await { 102 + Ok(Some(rsvp)) => rsvp, 103 + _ => return None, 104 + }; 105 + 106 + // Only proceed if the RSVP has been validated 107 + rsvp.validated_at?; 108 + 109 + // Deserialize the RSVP record to access signatures 110 + let rsvp_record: Rsvp = match serde_json::from_value(rsvp.record.0.clone()) { 111 + Ok(record) => record, 112 + Err(err) => { 113 + tracing::warn!("Failed to deserialize RSVP record: {}", err); 114 + return None; 115 + } 116 + }; 117 + 118 + // Find the organizer's acceptance signature in the RSVP 119 + let acceptance_uri = rsvp_record.signatures.iter().find_map(|sig| { 120 + if let SignatureOrRef::Reference(strongref) = sig { 121 + // Check if this signature is from the organizer 122 + let organizer_prefix = format!("at://{}/", organizer_did); 123 + if strongref.uri.starts_with(&organizer_prefix) { 124 + Some(strongref.uri.clone()) 125 + } else { 126 + None 127 + } 128 + } else { 129 + None 130 + } 131 + }); 132 + 133 + let acceptance_uri = match acceptance_uri { 134 + Some(uri) => uri, 135 + None => return None, 136 + }; 137 + 138 + // Fetch the acceptance record from the database 139 + let acceptance_record = match acceptance_record_get(pool, &acceptance_uri).await { 140 + Ok(Some(record)) => record.record.0, 141 + Ok(None) => { 142 + tracing::warn!( 143 + "Acceptance record not found in database: {}", 144 + acceptance_uri 145 + ); 146 + return None; 147 + } 148 + Err(err) => { 149 + tracing::warn!("Failed to fetch acceptance record from database: {}", err); 150 + return None; 151 + } 152 + }; 153 + 154 + tracing::info!(?acceptance_record, "acceptance_record"); 155 + 156 + // Parse the acceptance record to extract metadata 157 + match serde_json::from_value::<crate::atproto::lexicon::acceptance::Acceptance>( 158 + acceptance_record, 159 + ) { 160 + Ok(acceptance) if !acceptance.extra.is_empty() => { 161 + // Convert HashMap to Vec of (String, String) for template iteration 162 + let metadata_list: Vec<(String, String)> = acceptance 163 + .extra 164 + .into_iter() 165 + .map(|(key, value)| { 166 + let value_str = match value { 167 + serde_json::Value::String(s) => s, 168 + v => v.to_string(), 169 + }; 170 + (key, value_str) 171 + }) 172 + .collect(); 173 + Some(metadata_list) 174 + } 175 + _ => None, 84 176 } 85 177 } 86 178 ··· 306 398 }; 307 399 308 400 // Check if there's a pending acceptance ticket for the current user's RSVP 309 - let pending_acceptance = if let Some(current_entity) = &ctx.current_handle { 310 - match acceptance_ticket_get_by_event_and_rsvp_did( 311 - &ctx.web_context.pool, 312 - &aturi, 313 - &current_entity.did, 314 - ) 315 - .await 316 - { 317 - Ok(Some(ticket)) => Some(ticket.aturi), 318 - Ok(None) => None, 319 - Err(err) => { 320 - tracing::warn!("Error checking for pending acceptance: {:?}", err); 321 - None 401 + // OR if the RSVP has been validated, fetch the acceptance metadata from the organizer's PDS 402 + // Only check for acceptance if the user's RSVP status is "going" or "interested" (not "notgoing") 403 + let (pending_acceptance_aturi, pending_acceptance_metadata, validated_acceptance_metadata) = 404 + if let Some(current_entity) = &ctx.current_handle { 405 + // Only fetch acceptance data if the RSVP status is appropriate (going or interested) 406 + let should_check_acceptance = matches!( 407 + user_rsvp_status.as_deref(), 408 + Some("going") | Some("interested") 409 + ); 410 + 411 + if should_check_acceptance { 412 + // First check for validated acceptance metadata 413 + let validated_metadata = if user_rsvp_is_validated { 414 + fetch_validated_acceptance_metadata( 415 + &ctx.web_context.pool, 416 + &aturi, 417 + &current_entity.did, 418 + &profile.did, 419 + ) 420 + .await 421 + } else { 422 + None 423 + }; 424 + 425 + // Then check for pending acceptance ticket (not yet finalized) 426 + let (pending_aturi, pending_metadata) = 427 + match acceptance_ticket_get_by_event_and_rsvp_did( 428 + &ctx.web_context.pool, 429 + &aturi, 430 + &current_entity.did, 431 + ) 432 + .await 433 + { 434 + Ok(Some(ticket)) => { 435 + // Parse the acceptance record to extract custom metadata 436 + let metadata = if let Ok(acceptance) = 437 + serde_json::from_value::< 438 + crate::atproto::lexicon::acceptance::Acceptance, 439 + >(ticket.record.0) 440 + { 441 + if !acceptance.extra.is_empty() { 442 + // Convert HashMap to Vec of (String, String) for template iteration 443 + let metadata_list: Vec<(String, String)> = acceptance 444 + .extra 445 + .into_iter() 446 + .map(|(key, value)| { 447 + let value_str = match value { 448 + serde_json::Value::String(s) => s, 449 + v => v.to_string(), 450 + }; 451 + (key, value_str) 452 + }) 453 + .collect(); 454 + Some(metadata_list) 455 + } else { 456 + None 457 + } 458 + } else { 459 + None 460 + }; 461 + (Some(ticket.aturi), metadata) 462 + } 463 + Ok(None) => (None, None), 464 + Err(err) => { 465 + tracing::warn!("Error checking for pending acceptance: {:?}", err); 466 + (None, None) 467 + } 468 + }; 469 + 470 + (pending_aturi, pending_metadata, validated_metadata) 471 + } else { 472 + // User has no RSVP or has "notgoing" status - don't show acceptance 473 + (None, None, None) 322 474 } 323 - } 324 - } else { 325 - None 326 - }; 475 + } else { 476 + (None, None, None) 477 + }; 327 478 328 479 // Check if user has confirmed email 329 480 let user_has_confirmed_email = if let Some(current_entity) = &ctx.current_handle { ··· 479 630 active_tab_rsvps, 480 631 active_tab => tab_name, 481 632 user_rsvp_status, 482 - pending_acceptance, 633 + pending_acceptance => pending_acceptance_aturi, 634 + pending_acceptance_metadata, 635 + validated_acceptance_metadata, 483 636 handle_slug, 484 637 event_rkey, 485 638 collection => collection.clone(),
+11 -8
src/http/import_utils.rs
··· 4 4 use sqlx::PgPool; 5 5 6 6 use crate::storage::event::{ 7 - RsvpInsertParams, event_insert_with_metadata, rsvp_insert_with_metadata, 7 + EventInsertParams, RsvpInsertParams, event_insert_with_metadata, rsvp_insert_with_metadata, 8 8 }; 9 9 use atproto_record::lexicon::community::lexicon::calendar::{ 10 10 event::{Event as CommunityEvent, NSID as COMMUNITY_EVENT_NSID}, ··· 50 50 51 51 let error_status = match event_insert_with_metadata( 52 52 pool, 53 - &event_record.uri, 54 - &event_record.cid, 55 - did, 56 - COMMUNITY_EVENT_NSID, 57 - &event_record.value, 58 - &name, 59 - false, 53 + EventInsertParams { 54 + aturi: &event_record.uri, 55 + cid: &event_record.cid, 56 + did, 57 + lexicon: COMMUNITY_EVENT_NSID, 58 + record: &event_record.value, 59 + name: &name, 60 + require_confirmed_email: false, 61 + }, 60 62 ) 61 63 .await 62 64 { ··· 147 149 event_aturi: &event_aturi, 148 150 event_cid: &event_cid, 149 151 status, 152 + clear_validated_at: false, 150 153 }, 151 154 ) 152 155 .await
+5
src/http/mod.rs
··· 1 + pub mod acceptance_operations; 1 2 pub mod acceptance_utils; 2 3 pub mod auth_utils; 3 4 pub mod cache_countries; ··· 16 17 pub mod handle_admin_rsvp; 17 18 pub mod handle_admin_rsvps; 18 19 pub mod handle_blob; 20 + pub mod handle_bulk_accept_rsvps; 19 21 pub mod handle_content; 20 22 pub mod handle_create_event; 21 23 pub mod handle_create_rsvp; ··· 30 32 pub mod handle_import; 31 33 pub mod handle_index; 32 34 pub mod handle_mailgun_webhook; 35 + pub mod handle_manage_event; 36 + pub mod handle_manage_event_content; 33 37 pub mod handle_oauth_aip_callback; 34 38 pub mod handle_oauth_aip_login; 35 39 pub mod handle_oauth_callback; ··· 39 43 pub mod handle_profile; 40 44 pub mod handle_set_language; 41 45 pub mod handle_settings; 46 + pub mod handle_unaccept_rsvp; 42 47 pub mod handle_unsubscribe; 43 48 pub mod handle_view_event; 44 49 pub mod handle_wellknown;
+14 -1
src/http/server.rs
··· 31 31 handle_blob::{ 32 32 delete_profile_avatar, delete_profile_banner, upload_profile_avatar, upload_profile_banner, 33 33 }, 34 + handle_bulk_accept_rsvps::handle_bulk_accept_rsvps, 34 35 handle_content::handle_content, 35 36 handle_create_event::{ 36 37 handle_create_event, handle_location_at_builder, handle_location_datalist, ··· 48 49 handle_import::{handle_import, handle_import_submit}, 49 50 handle_index::handle_index, 50 51 handle_mailgun_webhook::handle_mailgun_webhook, 52 + handle_manage_event::handle_manage_event, 53 + handle_manage_event_content::handle_manage_event_content_save, 51 54 handle_oauth_logout::handle_logout, 52 55 handle_policy::{ 53 56 handle_about, handle_acknowledgement, handle_cookie_policy, handle_privacy_policy, ··· 61 64 handle_profile_update, handle_remove_webhook, handle_settings, handle_test_webhook, 62 65 handle_timezone_update, handle_toggle_webhook, 63 66 }, 67 + handle_unaccept_rsvp::handle_unaccept_rsvp, 64 68 handle_unsubscribe::handle_unsubscribe, 65 69 handle_view_event::handle_view_event, 66 70 handle_wellknown::handle_wellknown_did_web, ··· 185 189 .route("/rsvp", get(handle_create_rsvp)) 186 190 .route("/rsvp", post(handle_create_rsvp)) 187 191 .route("/accept_rsvp", post(handle_accept_rsvp)) 192 + .route("/bulk_accept_rsvps", post(handle_bulk_accept_rsvps)) 193 + .route("/unaccept_rsvp", post(handle_unaccept_rsvp)) 188 194 .route("/finalize_acceptance", post(handle_finalize_acceptance)) 189 195 .route("/event/starts", get(handle_starts_at_builder)) 190 196 .route("/event/starts", post(handle_starts_at_builder)) ··· 193 199 .route("/event/location/datalist", get(handle_location_datalist)) 194 200 .route("/ics/{*aturi}", get(handle_export_ics)) 195 201 .route("/{handle_slug}/ics", get(handle_event_ics)) 196 - .route("/{handle_slug}/{event_rkey}/edit", get(handle_edit_event)) 197 202 .route("/{handle_slug}/{event_rkey}/edit", post(handle_edit_event)) 203 + .route( 204 + "/{handle_slug}/{event_rkey}/manage", 205 + get(handle_manage_event), 206 + ) 207 + .route( 208 + "/{handle_slug}/{event_rkey}/manage/content", 209 + post(handle_manage_event_content_save), 210 + ) 198 211 .route( 199 212 "/{handle_slug}/{event_rkey}/export-rsvps", 200 213 get(handle_export_rsvps),
+17 -11
src/processor.rs
··· 10 10 use serde_json::Value; 11 11 use std::sync::Arc; 12 12 13 - use crate::atproto::lexicon::acceptance::{Acceptance, NSID as AcceptanceNSID}; 13 + use crate::atproto::lexicon::acceptance::NSID as AcceptanceNSID; 14 + use crate::atproto::lexicon::acceptance::TypedAcceptance; 14 15 use crate::atproto::lexicon::profile::{NSID as ProfileNSID, Profile}; 15 16 use crate::consumer::SmokeSignalEvent; 16 17 use crate::consumer::SmokeSignalEventReceiver; ··· 21 22 }; 22 23 use crate::storage::content::ContentStorage; 23 24 use crate::storage::denylist::denylist_exists; 25 + use crate::storage::event::EventInsertParams; 24 26 use crate::storage::event::RsvpInsertParams; 25 27 use crate::storage::event::event_delete; 26 28 use crate::storage::event::event_exists; ··· 166 168 167 169 event_insert_with_metadata( 168 170 &self.pool, 169 - &aturi, 170 - cid, 171 - did, 172 - LexiconCommunityEventNSID, 173 - &event_record, 174 - &name, 175 - false, 171 + EventInsertParams { 172 + aturi: &aturi, 173 + cid, 174 + did, 175 + lexicon: LexiconCommunityEventNSID, 176 + record: &event_record, 177 + name: &name, 178 + require_confirmed_email: false, 179 + }, 176 180 ) 177 181 .await?; 178 182 ··· 227 231 event_aturi: &event_aturi, 228 232 event_cid: &event_cid, 229 233 status, 234 + clear_validated_at: false, 230 235 }, 231 236 ) 232 237 .await?; ··· 359 364 &self, 360 365 did: &str, 361 366 rkey: &str, 362 - _cid: &str, 367 + cid: &str, 363 368 record: &Value, 364 369 ) -> Result<()> { 365 370 tracing::info!("Processing acceptance: {} for {}", rkey, did); ··· 367 372 let aturi = format!("at://{did}/{AcceptanceNSID}/{rkey}"); 368 373 369 374 // Deserialize and validate the acceptance record 370 - let acceptance_record: Acceptance = serde_json::from_value(record.clone())?; 375 + let acceptance_record: TypedAcceptance = serde_json::from_value(record.clone())?; 376 + tracing::info!(?acceptance_record, "acceptance_record"); 371 377 372 378 // Validate the acceptance record 373 379 if let Err(e) = acceptance_record.validate() { ··· 376 382 } 377 383 378 384 // Store the acceptance record 379 - acceptance_record_upsert(&self.pool, &aturi, &acceptance_record.cid, did, record).await?; 385 + acceptance_record_upsert(&self.pool, &aturi, cid, did, record).await?; 380 386 381 387 tracing::info!("Acceptance stored: {}", aturi); 382 388 Ok(())
+68 -1
src/storage/acceptance.rs
··· 29 29 } 30 30 31 31 /// Insert or update an acceptance ticket 32 + /// When inserting a new ticket, old tickets for the same event+rsvp_did are deleted 33 + /// to prevent accumulation of stale tickets (e.g., when organizer re-accepts with new metadata) 32 34 pub async fn acceptance_ticket_upsert<T: serde::Serialize>( 33 35 pool: &StoragePool, 34 36 aturi: &str, ··· 44 46 45 47 let now = Utc::now(); 46 48 49 + // First, delete any existing tickets for this event+rsvp_did combination 50 + // This handles the case where an organizer re-accepts with updated metadata (new CID) 51 + sqlx::query( 52 + "DELETE FROM acceptance_tickets WHERE event_aturi = $1 AND rsvp_did = $2 AND aturi != $3", 53 + ) 54 + .bind(event_aturi) 55 + .bind(rsvp_did) 56 + .bind(aturi) 57 + .execute(tx.as_mut()) 58 + .await 59 + .map_err(StorageError::UnableToExecuteQuery)?; 60 + 61 + // Now insert or update the current ticket 47 62 sqlx::query( 48 63 "INSERT INTO acceptance_tickets (aturi, did, rsvp_did, event_aturi, record, created_at) 49 64 VALUES ($1, $2, $3, $4, $5, $6) ··· 286 301 } 287 302 288 303 /// Get acceptance ticket for a specific event and RSVP DID combination 304 + /// Returns the most recent ticket if multiple exist (e.g., after re-acceptance with updated metadata) 289 305 pub async fn acceptance_ticket_get_by_event_and_rsvp_did( 290 306 pool: &StoragePool, 291 307 event_aturi: &str, ··· 297 313 .map_err(StorageError::CannotBeginDatabaseTransaction)?; 298 314 299 315 let record = sqlx::query_as::<_, AcceptanceTicket>( 300 - "SELECT * FROM acceptance_tickets WHERE event_aturi = $1 AND rsvp_did = $2 LIMIT 1", 316 + "SELECT * FROM acceptance_tickets WHERE event_aturi = $1 AND rsvp_did = $2 ORDER BY created_at DESC LIMIT 1", 301 317 ) 302 318 .bind(event_aturi) 303 319 .bind(rsvp_did) ··· 312 328 Ok(record) 313 329 } 314 330 331 + /// Delete acceptance ticket by event and RSVP DID 332 + pub async fn acceptance_ticket_delete_by_event_and_rsvp_did( 333 + pool: &StoragePool, 334 + event_aturi: &str, 335 + rsvp_did: &str, 336 + ) -> Result<(), StorageError> { 337 + let mut tx = pool 338 + .begin() 339 + .await 340 + .map_err(StorageError::CannotBeginDatabaseTransaction)?; 341 + 342 + sqlx::query("DELETE FROM acceptance_tickets WHERE event_aturi = $1 AND rsvp_did = $2") 343 + .bind(event_aturi) 344 + .bind(rsvp_did) 345 + .execute(tx.as_mut()) 346 + .await 347 + .map_err(StorageError::UnableToExecuteQuery)?; 348 + 349 + tx.commit() 350 + .await 351 + .map_err(StorageError::CannotCommitDatabaseTransaction) 352 + } 353 + 315 354 /// Update RSVP validated_at timestamp 316 355 pub async fn rsvp_update_validated_at( 317 356 pool: &StoragePool, ··· 334 373 .await 335 374 .map_err(StorageError::CannotCommitDatabaseTransaction) 336 375 } 376 + 377 + /// Clear validated_at timestamp for all RSVPs of a given event 378 + /// This should be called when event details change to invalidate existing validations 379 + pub async fn rsvp_clear_event_validations( 380 + pool: &StoragePool, 381 + event_aturi: &str, 382 + ) -> Result<(), StorageError> { 383 + let mut tx = pool 384 + .begin() 385 + .await 386 + .map_err(StorageError::CannotBeginDatabaseTransaction)?; 387 + 388 + let result = sqlx::query("UPDATE rsvps SET validated_at = NULL WHERE event_aturi = $1") 389 + .bind(event_aturi) 390 + .execute(tx.as_mut()) 391 + .await 392 + .map_err(StorageError::UnableToExecuteQuery)?; 393 + 394 + tracing::info!( 395 + event_aturi = ?event_aturi, 396 + rows_affected = result.rows_affected(), 397 + "Cleared RSVP validations for event" 398 + ); 399 + 400 + tx.commit() 401 + .await 402 + .map_err(StorageError::CannotCommitDatabaseTransaction) 403 + }
+69 -24
src/storage/event.rs
··· 93 93 let name = &record.name; 94 94 95 95 // Call the new function with extracted values 96 - event_insert_with_metadata(pool, aturi, cid, did, lexicon, record, name, false).await 96 + event_insert_with_metadata( 97 + pool, 98 + EventInsertParams { 99 + aturi, 100 + cid, 101 + did, 102 + lexicon, 103 + record, 104 + name, 105 + require_confirmed_email: false, 106 + }, 107 + ) 108 + .await 109 + } 110 + 111 + pub struct EventInsertParams<'a, T: serde::Serialize> { 112 + pub aturi: &'a str, 113 + pub cid: &'a str, 114 + pub did: &'a str, 115 + pub lexicon: &'a str, 116 + pub record: &'a T, 117 + pub name: &'a str, 118 + pub require_confirmed_email: bool, 97 119 } 98 120 99 121 pub async fn event_insert_with_metadata<T: serde::Serialize>( 100 122 pool: &StoragePool, 101 - aturi: &str, 102 - cid: &str, 103 - did: &str, 104 - lexicon: &str, 105 - record: &T, 106 - name: &str, 107 - require_confirmed_email: bool, 123 + params: EventInsertParams<'_, T>, 108 124 ) -> Result<(), StorageError> { 109 125 let mut tx = pool 110 126 .begin() ··· 114 130 let now = Utc::now(); 115 131 116 132 sqlx::query("INSERT INTO events (aturi, cid, did, lexicon, record, name, require_confirmed_email, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (aturi) DO UPDATE SET cid = $2, record = $5, name = $6, require_confirmed_email = $7, updated_at = $8") 117 - .bind(aturi) 118 - .bind(cid) 119 - .bind(did) 120 - .bind(lexicon) 121 - .bind(json!(record)) 122 - .bind(name) 123 - .bind(require_confirmed_email) 133 + .bind(params.aturi) 134 + .bind(params.cid) 135 + .bind(params.did) 136 + .bind(params.lexicon) 137 + .bind(json!(params.record)) 138 + .bind(params.name) 139 + .bind(params.require_confirmed_email) 124 140 .bind(now) 125 141 .execute(tx.as_mut()) 126 142 .await ··· 140 156 pub event_aturi: &'a str, 141 157 pub event_cid: &'a str, 142 158 pub status: &'a str, 159 + pub clear_validated_at: bool, 143 160 } 144 161 145 162 pub async fn rsvp_insert_with_metadata<T: serde::Serialize>( ··· 154 171 let now = Utc::now(); 155 172 156 173 // TODO: This should probably also update event_aturi and event_cid 157 - 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") 174 + // When clear_validated_at is true (status changed), we clear validated_at to NULL 175 + // When clear_validated_at is false (status unchanged), we preserve validated_at by not updating it 176 + if params.clear_validated_at { 177 + 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, validated_at = NULL") 158 178 .bind(params.aturi) 159 179 .bind(params.cid) 160 180 .bind(params.did) ··· 167 187 .execute(tx.as_mut()) 168 188 .await 169 189 .map_err(StorageError::UnableToExecuteQuery)?; 190 + } else { 191 + 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") 192 + .bind(params.aturi) 193 + .bind(params.cid) 194 + .bind(params.did) 195 + .bind(params.lexicon) 196 + .bind(json!(params.record)) 197 + .bind(params.event_aturi) 198 + .bind(params.event_cid) 199 + .bind(params.status) 200 + .bind(now) 201 + .execute(tx.as_mut()) 202 + .await 203 + .map_err(StorageError::UnableToExecuteQuery)?; 204 + } 170 205 171 206 tx.commit() 172 207 .await ··· 202 237 event_aturi: &event_aturi, 203 238 event_cid: &event_cid, 204 239 status, 240 + clear_validated_at: false, 205 241 }, 206 242 ) 207 243 .await ··· 1180 1216 pub handle: Option<String>, 1181 1217 pub status: String, 1182 1218 pub created_at: Option<chrono::DateTime<chrono::Utc>>, 1219 + #[serde(skip_serializing_if = "Option::is_none")] 1220 + pub is_accepted: Option<bool>, 1221 + pub validated_at: Option<chrono::DateTime<chrono::Utc>>, 1183 1222 } 1184 1223 1185 1224 #[derive(Debug, Serialize, Clone)] ··· 1213 1252 r.did, 1214 1253 ip.handle, 1215 1254 r.status, 1216 - r.updated_at 1255 + r.updated_at, 1256 + r.validated_at 1217 1257 FROM rsvps r 1218 1258 LEFT JOIN identity_profiles ip ON r.did = ip.did 1219 1259 WHERE r.event_aturi = $1 ··· 1228 1268 String, 1229 1269 Option<String>, 1230 1270 String, 1271 + Option<chrono::DateTime<chrono::Utc>>, 1231 1272 Option<chrono::DateTime<chrono::Utc>>, 1232 1273 ), 1233 1274 >(query) ··· 1243 1284 let export_data: Vec<RsvpExportData> = rsvps 1244 1285 .into_iter() 1245 1286 .map( 1246 - |(event_aturi, rsvp_aturi, did, handle, status, created_at)| RsvpExportData { 1247 - event_aturi, 1248 - rsvp_aturi, 1249 - did, 1250 - handle, 1251 - status, 1252 - created_at, 1287 + |(event_aturi, rsvp_aturi, did, handle, status, created_at, validated_at)| { 1288 + RsvpExportData { 1289 + event_aturi, 1290 + rsvp_aturi, 1291 + did, 1292 + handle, 1293 + status, 1294 + created_at, 1295 + is_accepted: None, 1296 + validated_at, 1297 + } 1253 1298 }, 1254 1299 ) 1255 1300 .collect();
+5
static/alpine.js
··· 1 + (()=>{var nt=!1,it=!1,W=[],ot=-1;function Ut(e){Rn(e)}function Rn(e){W.includes(e)||W.push(e),Mn()}function Wt(e){let t=W.indexOf(e);t!==-1&&t>ot&&W.splice(t,1)}function Mn(){!it&&!nt&&(nt=!0,queueMicrotask(Nn))}function Nn(){nt=!1,it=!0;for(let e=0;e<W.length;e++)W[e](),ot=e;W.length=0,ot=-1,it=!1}var T,N,$,at,st=!0;function Gt(e){st=!1,e(),st=!0}function Jt(e){T=e.reactive,$=e.release,N=t=>e.effect(t,{scheduler:r=>{st?Ut(r):r()}}),at=e.raw}function ct(e){N=e}function Yt(e){let t=()=>{};return[n=>{let i=N(n);return e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(o=>o())}),e._x_effects.add(i),t=()=>{i!==void 0&&(e._x_effects.delete(i),$(i))},i},()=>{t()}]}function ve(e,t){let r=!0,n,i=N(()=>{let o=e();JSON.stringify(o),r?n=o:queueMicrotask(()=>{t(o,n),n=o}),r=!1});return()=>$(i)}var Xt=[],Zt=[],Qt=[];function er(e){Qt.push(e)}function te(e,t){typeof t=="function"?(e._x_cleanups||(e._x_cleanups=[]),e._x_cleanups.push(t)):(t=e,Zt.push(t))}function Ae(e){Xt.push(e)}function Oe(e,t,r){e._x_attributeCleanups||(e._x_attributeCleanups={}),e._x_attributeCleanups[t]||(e._x_attributeCleanups[t]=[]),e._x_attributeCleanups[t].push(r)}function lt(e,t){e._x_attributeCleanups&&Object.entries(e._x_attributeCleanups).forEach(([r,n])=>{(t===void 0||t.includes(r))&&(n.forEach(i=>i()),delete e._x_attributeCleanups[r])})}function tr(e){for(e._x_effects?.forEach(Wt);e._x_cleanups?.length;)e._x_cleanups.pop()()}var ut=new MutationObserver(mt),ft=!1;function ue(){ut.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),ft=!0}function dt(){kn(),ut.disconnect(),ft=!1}var le=[];function kn(){let e=ut.takeRecords();le.push(()=>e.length>0&&mt(e));let t=le.length;queueMicrotask(()=>{if(le.length===t)for(;le.length>0;)le.shift()()})}function m(e){if(!ft)return e();dt();let t=e();return ue(),t}var pt=!1,Se=[];function rr(){pt=!0}function nr(){pt=!1,mt(Se),Se=[]}function mt(e){if(pt){Se=Se.concat(e);return}let t=[],r=new Set,n=new Map,i=new Map;for(let o=0;o<e.length;o++)if(!e[o].target._x_ignoreMutationObserver&&(e[o].type==="childList"&&(e[o].removedNodes.forEach(s=>{s.nodeType===1&&s._x_marker&&r.add(s)}),e[o].addedNodes.forEach(s=>{if(s.nodeType===1){if(r.has(s)){r.delete(s);return}s._x_marker||t.push(s)}})),e[o].type==="attributes")){let s=e[o].target,a=e[o].attributeName,c=e[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},u=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(u(),l()):u()}i.forEach((o,s)=>{lt(s,o)}),n.forEach((o,s)=>{Xt.forEach(a=>a(s,o))});for(let o of r)t.some(s=>s.contains(o))||Zt.forEach(s=>s(o));for(let o of t)o.isConnected&&Qt.forEach(s=>s(o));t=null,r=null,n=null,i=null}function Ce(e){return z(B(e))}function k(e,t,r){return e._x_dataStack=[t,...B(r||e)],()=>{e._x_dataStack=e._x_dataStack.filter(n=>n!==t)}}function B(e){return e._x_dataStack?e._x_dataStack:typeof ShadowRoot=="function"&&e instanceof ShadowRoot?B(e.host):e.parentNode?B(e.parentNode):[]}function z(e){return new Proxy({objects:e},Dn)}var Dn={ownKeys({objects:e}){return Array.from(new Set(e.flatMap(t=>Object.keys(t))))},has({objects:e},t){return t==Symbol.unscopables?!1:e.some(r=>Object.prototype.hasOwnProperty.call(r,t)||Reflect.has(r,t))},get({objects:e},t,r){return t=="toJSON"?Pn:Reflect.get(e.find(n=>Reflect.has(n,t))||{},t,r)},set({objects:e},t,r,n){let i=e.find(s=>Object.prototype.hasOwnProperty.call(s,t))||e[e.length-1],o=Object.getOwnPropertyDescriptor(i,t);return o?.set&&o?.get?o.set.call(n,r)||!0:Reflect.set(i,t,r)}};function Pn(){return Reflect.ownKeys(this).reduce((t,r)=>(t[r]=Reflect.get(this,r),t),{})}function Te(e){let t=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0||typeof s=="object"&&s!==null&&s.__v_skip)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(e,c,o):t(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(e)}function Re(e,t=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return e(this.initialValue,()=>In(n,i),s=>ht(n,i,s),i,o)}};return t(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function In(e,t){return t.split(".").reduce((r,n)=>r[n],e)}function ht(e,t,r){if(typeof t=="string"&&(t=t.split(".")),t.length===1)e[t[0]]=r;else{if(t.length===0)throw error;return e[t[0]]||(e[t[0]]={}),ht(e[t[0]],t.slice(1),r)}}var ir={};function y(e,t){ir[e]=t}function fe(e,t){let r=Ln(t);return Object.entries(ir).forEach(([n,i])=>{Object.defineProperty(e,`$${n}`,{get(){return i(t,r)},enumerable:!1})}),e}function Ln(e){let[t,r]=_t(e),n={interceptor:Re,...t};return te(e,r),n}function or(e,t,r,...n){try{return r(...n)}catch(i){re(i,e,t)}}function re(e,t,r=void 0){e=Object.assign(e??{message:"No error message given."},{el:t,expression:r}),console.warn(`Alpine Expression Error: ${e.message} 2 + 3 + ${r?'Expression: "'+r+`" 4 + 5 + `:""}`,t),setTimeout(()=>{throw e},0)}var Me=!0;function ke(e){let t=Me;Me=!1;let r=e();return Me=t,r}function R(e,t,r={}){let n;return x(e,t)(i=>n=i,r),n}function x(...e){return sr(...e)}var sr=xt;function ar(e){sr=e}function xt(e,t){let r={};fe(r,e);let n=[r,...B(e)],i=typeof t=="function"?$n(n,t):Fn(n,t,e);return or.bind(null,e,t,i)}function $n(e,t){return(r=()=>{},{scope:n={},params:i=[],context:o}={})=>{let s=t.apply(z([n,...e]),i);Ne(r,s)}}var gt={};function jn(e,t){if(gt[e])return gt[e];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${e}`}),s}catch(s){return re(s,t,e),Promise.resolve()}})();return gt[e]=o,o}function Fn(e,t,r){let n=jn(t,r);return(i=()=>{},{scope:o={},params:s=[],context:a}={})=>{n.result=void 0,n.finished=!1;let c=z([o,...e]);if(typeof n=="function"){let l=n.call(a,n,c).catch(u=>re(u,r,t));n.finished?(Ne(i,n.result,c,s,r),n.result=void 0):l.then(u=>{Ne(i,u,c,s,r)}).catch(u=>re(u,r,t)).finally(()=>n.result=void 0)}}}function Ne(e,t,r,n,i){if(Me&&typeof t=="function"){let o=t.apply(r,n);o instanceof Promise?o.then(s=>Ne(e,s,r,n)).catch(s=>re(s,i,t)):e(o)}else typeof t=="object"&&t instanceof Promise?t.then(o=>e(o)):e(t)}var wt="x-";function C(e=""){return wt+e}function cr(e){wt=e}var De={};function d(e,t){return De[e]=t,{before(r){if(!De[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${e}\` will use the default order of execution`);return}let n=G.indexOf(r);G.splice(n>=0?n:G.indexOf("DEFAULT"),0,e)}}}function lr(e){return Object.keys(De).includes(e)}function pe(e,t,r){if(t=Array.from(t),e._x_virtualDirectives){let o=Object.entries(e._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=Et(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),t=t.concat(o)}let n={};return t.map(dr((o,s)=>n[o]=s)).filter(mr).map(zn(n,r)).sort(Kn).map(o=>Bn(e,o))}function Et(e){return Array.from(e).map(dr()).filter(t=>!mr(t))}var yt=!1,de=new Map,ur=Symbol();function fr(e){yt=!0;let t=Symbol();ur=t,de.set(t,[]);let r=()=>{for(;de.get(t).length;)de.get(t).shift()();de.delete(t)},n=()=>{yt=!1,r()};e(r),n()}function _t(e){let t=[],r=a=>t.push(a),[n,i]=Yt(e);return t.push(i),[{Alpine:K,effect:n,cleanup:r,evaluateLater:x.bind(x,e),evaluate:R.bind(R,e)},()=>t.forEach(a=>a())]}function Bn(e,t){let r=()=>{},n=De[t.type]||r,[i,o]=_t(e);Oe(e,t.original,o);let s=()=>{e._x_ignore||e._x_ignoreSelf||(n.inline&&n.inline(e,t,i),n=n.bind(n,e,t,i),yt?de.get(ur).push(n):n())};return s.runCleanups=o,s}var Pe=(e,t)=>({name:r,value:n})=>(r.startsWith(e)&&(r=r.replace(e,t)),{name:r,value:n}),Ie=e=>e;function dr(e=()=>{}){return({name:t,value:r})=>{let{name:n,value:i}=pr.reduce((o,s)=>s(o),{name:t,value:r});return n!==t&&e(n,t),{name:n,value:i}}}var pr=[];function ne(e){pr.push(e)}function mr({name:e}){return hr().test(e)}var hr=()=>new RegExp(`^${wt}([^:^.]+)\\b`);function zn(e,t){return({name:r,value:n})=>{let i=r.match(hr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=t||e[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var bt="DEFAULT",G=["ignore","ref","data","id","anchor","bind","init","for","model","modelable","transition","show","if",bt,"teleport"];function Kn(e,t){let r=G.indexOf(e.type)===-1?bt:e.type,n=G.indexOf(t.type)===-1?bt:t.type;return G.indexOf(r)-G.indexOf(n)}function J(e,t,r={}){e.dispatchEvent(new CustomEvent(t,{detail:r,bubbles:!0,composed:!0,cancelable:!0}))}function D(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>D(i,t));return}let r=!1;if(t(e,()=>r=!0),r)return;let n=e.firstElementChild;for(;n;)D(n,t,!1),n=n.nextElementSibling}function E(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var _r=!1;function gr(){_r&&E("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),_r=!0,document.body||E("Unable to initialize. Trying to load Alpine before `<body>` is available. Did you forget to add `defer` in Alpine's `<script>` tag?"),J(document,"alpine:init"),J(document,"alpine:initializing"),ue(),er(t=>S(t,D)),te(t=>P(t)),Ae((t,r)=>{pe(t,r).forEach(n=>n())});let e=t=>!Y(t.parentElement,!0);Array.from(document.querySelectorAll(br().join(","))).filter(e).forEach(t=>{S(t)}),J(document,"alpine:initialized"),setTimeout(()=>{Vn()})}var vt=[],xr=[];function yr(){return vt.map(e=>e())}function br(){return vt.concat(xr).map(e=>e())}function Le(e){vt.push(e)}function $e(e){xr.push(e)}function Y(e,t=!1){return j(e,r=>{if((t?br():yr()).some(i=>r.matches(i)))return!0})}function j(e,t){if(e){if(t(e))return e;if(e._x_teleportBack&&(e=e._x_teleportBack),!!e.parentElement)return j(e.parentElement,t)}}function wr(e){return yr().some(t=>e.matches(t))}var Er=[];function vr(e){Er.push(e)}var Hn=1;function S(e,t=D,r=()=>{}){j(e,n=>n._x_ignore)||fr(()=>{t(e,(n,i)=>{n._x_marker||(r(n,i),Er.forEach(o=>o(n,i)),pe(n,n.attributes).forEach(o=>o()),n._x_ignore||(n._x_marker=Hn++),n._x_ignore&&i())})})}function P(e,t=D){t(e,r=>{tr(r),lt(r),delete r._x_marker})}function Vn(){[["ui","dialog",["[x-dialog], [x-popover]"]],["anchor","anchor",["[x-anchor]"]],["sort","sort",["[x-sort]"]]].forEach(([t,r,n])=>{lr(r)||n.some(i=>{if(document.querySelector(i))return E(`found "${i}", but missing ${t} plugin`),!0})})}var St=[],At=!1;function ie(e=()=>{}){return queueMicrotask(()=>{At||setTimeout(()=>{je()})}),new Promise(t=>{St.push(()=>{e(),t()})})}function je(){for(At=!1;St.length;)St.shift()()}function Sr(){At=!0}function me(e,t){return Array.isArray(t)?Ar(e,t.join(" ")):typeof t=="object"&&t!==null?qn(e,t):typeof t=="function"?me(e,t()):Ar(e,t)}function Ar(e,t){let r=o=>o.split(" ").filter(Boolean),n=o=>o.split(" ").filter(s=>!e.classList.contains(s)).filter(Boolean),i=o=>(e.classList.add(...o),()=>{e.classList.remove(...o)});return t=t===!0?t="":t||"",i(n(t))}function qn(e,t){let r=a=>a.split(" ").filter(Boolean),n=Object.entries(t).flatMap(([a,c])=>c?r(a):!1).filter(Boolean),i=Object.entries(t).flatMap(([a,c])=>c?!1:r(a)).filter(Boolean),o=[],s=[];return i.forEach(a=>{e.classList.contains(a)&&(e.classList.remove(a),s.push(a))}),n.forEach(a=>{e.classList.contains(a)||(e.classList.add(a),o.push(a))}),()=>{s.forEach(a=>e.classList.add(a)),o.forEach(a=>e.classList.remove(a))}}function X(e,t){return typeof t=="object"&&t!==null?Un(e,t):Wn(e,t)}function Un(e,t){let r={};return Object.entries(t).forEach(([n,i])=>{r[n]=e.style[n],n.startsWith("--")||(n=Gn(n)),e.style.setProperty(n,i)}),setTimeout(()=>{e.style.length===0&&e.removeAttribute("style")}),()=>{X(e,r)}}function Wn(e,t){let r=e.getAttribute("style",t);return e.setAttribute("style",t),()=>{e.setAttribute("style",r||"")}}function Gn(e){return e.replace(/([a-z])([A-Z])/g,"$1-$2").toLowerCase()}function he(e,t=()=>{}){let r=!1;return function(){r?t.apply(this,arguments):(r=!0,e.apply(this,arguments))}}d("transition",(e,{value:t,modifiers:r,expression:n},{evaluate:i})=>{typeof n=="function"&&(n=i(n)),n!==!1&&(!n||typeof n=="boolean"?Yn(e,r,t):Jn(e,n,t))});function Jn(e,t,r){Or(e,me,""),{enter:i=>{e._x_transition.enter.during=i},"enter-start":i=>{e._x_transition.enter.start=i},"enter-end":i=>{e._x_transition.enter.end=i},leave:i=>{e._x_transition.leave.during=i},"leave-start":i=>{e._x_transition.leave.start=i},"leave-end":i=>{e._x_transition.leave.end=i}}[r](t)}function Yn(e,t,r){Or(e,X);let n=!t.includes("in")&&!t.includes("out")&&!r,i=n||t.includes("in")||["enter"].includes(r),o=n||t.includes("out")||["leave"].includes(r);t.includes("in")&&!n&&(t=t.filter((g,b)=>b<t.indexOf("out"))),t.includes("out")&&!n&&(t=t.filter((g,b)=>b>t.indexOf("out")));let s=!t.includes("opacity")&&!t.includes("scale"),a=s||t.includes("opacity"),c=s||t.includes("scale"),l=a?0:1,u=c?_e(t,"scale",95)/100:1,p=_e(t,"delay",0)/1e3,h=_e(t,"origin","center"),w="opacity, transform",F=_e(t,"duration",150)/1e3,Ee=_e(t,"duration",75)/1e3,f="cubic-bezier(0.4, 0.0, 0.2, 1)";i&&(e._x_transition.enter.during={transformOrigin:h,transitionDelay:`${p}s`,transitionProperty:w,transitionDuration:`${F}s`,transitionTimingFunction:f},e._x_transition.enter.start={opacity:l,transform:`scale(${u})`},e._x_transition.enter.end={opacity:1,transform:"scale(1)"}),o&&(e._x_transition.leave.during={transformOrigin:h,transitionDelay:`${p}s`,transitionProperty:w,transitionDuration:`${Ee}s`,transitionTimingFunction:f},e._x_transition.leave.start={opacity:1,transform:"scale(1)"},e._x_transition.leave.end={opacity:l,transform:`scale(${u})`})}function Or(e,t,r={}){e._x_transition||(e._x_transition={enter:{during:r,start:r,end:r},leave:{during:r,start:r,end:r},in(n=()=>{},i=()=>{}){Fe(e,t,{during:this.enter.during,start:this.enter.start,end:this.enter.end},n,i)},out(n=()=>{},i=()=>{}){Fe(e,t,{during:this.leave.during,start:this.leave.start,end:this.leave.end},n,i)}})}window.Element.prototype._x_toggleAndCascadeWithTransitions=function(e,t,r,n){let i=document.visibilityState==="visible"?requestAnimationFrame:setTimeout,o=()=>i(r);if(t){e._x_transition&&(e._x_transition.enter||e._x_transition.leave)?e._x_transition.enter&&(Object.entries(e._x_transition.enter.during).length||Object.entries(e._x_transition.enter.start).length||Object.entries(e._x_transition.enter.end).length)?e._x_transition.in(r):o():e._x_transition?e._x_transition.in(r):o();return}e._x_hidePromise=e._x_transition?new Promise((s,a)=>{e._x_transition.out(()=>{},()=>s(n)),e._x_transitioning&&e._x_transitioning.beforeCancel(()=>a({isFromCancelledTransition:!0}))}):Promise.resolve(n),queueMicrotask(()=>{let s=Cr(e);s?(s._x_hideChildren||(s._x_hideChildren=[]),s._x_hideChildren.push(e)):i(()=>{let a=c=>{let l=Promise.all([c._x_hidePromise,...(c._x_hideChildren||[]).map(a)]).then(([u])=>u?.());return delete c._x_hidePromise,delete c._x_hideChildren,l};a(e).catch(c=>{if(!c.isFromCancelledTransition)throw c})})})};function Cr(e){let t=e.parentNode;if(t)return t._x_hidePromise?t:Cr(t)}function Fe(e,t,{during:r,start:n,end:i}={},o=()=>{},s=()=>{}){if(e._x_transitioning&&e._x_transitioning.cancel(),Object.keys(r).length===0&&Object.keys(n).length===0&&Object.keys(i).length===0){o(),s();return}let a,c,l;Xn(e,{start(){a=t(e,n)},during(){c=t(e,r)},before:o,end(){a(),l=t(e,i)},after:s,cleanup(){c(),l()}})}function Xn(e,t){let r,n,i,o=he(()=>{m(()=>{r=!0,n||t.before(),i||(t.end(),je()),t.after(),e.isConnected&&t.cleanup(),delete e._x_transitioning})});e._x_transitioning={beforeCancels:[],beforeCancel(s){this.beforeCancels.push(s)},cancel:he(function(){for(;this.beforeCancels.length;)this.beforeCancels.shift()();o()}),finish:o},m(()=>{t.start(),t.during()}),Sr(),requestAnimationFrame(()=>{if(r)return;let s=Number(getComputedStyle(e).transitionDuration.replace(/,.*/,"").replace("s",""))*1e3,a=Number(getComputedStyle(e).transitionDelay.replace(/,.*/,"").replace("s",""))*1e3;s===0&&(s=Number(getComputedStyle(e).animationDuration.replace("s",""))*1e3),m(()=>{t.before()}),n=!0,requestAnimationFrame(()=>{r||(m(()=>{t.end()}),je(),setTimeout(e._x_transitioning.finish,s+a),i=!0)})})}function _e(e,t,r){if(e.indexOf(t)===-1)return r;let n=e[e.indexOf(t)+1];if(!n||t==="scale"&&isNaN(n))return r;if(t==="duration"||t==="delay"){let i=n.match(/([0-9]+)ms/);if(i)return i[1]}return t==="origin"&&["top","right","left","center","bottom"].includes(e[e.indexOf(t)+2])?[n,e[e.indexOf(t)+2]].join(" "):n}var I=!1;function A(e,t=()=>{}){return(...r)=>I?t(...r):e(...r)}function Tr(e){return(...t)=>I&&e(...t)}var Rr=[];function H(e){Rr.push(e)}function Mr(e,t){Rr.forEach(r=>r(e,t)),I=!0,kr(()=>{S(t,(r,n)=>{n(r,()=>{})})}),I=!1}var Be=!1;function Nr(e,t){t._x_dataStack||(t._x_dataStack=e._x_dataStack),I=!0,Be=!0,kr(()=>{Zn(t)}),I=!1,Be=!1}function Zn(e){let t=!1;S(e,(n,i)=>{D(n,(o,s)=>{if(t&&wr(o))return s();t=!0,i(o,s)})})}function kr(e){let t=N;ct((r,n)=>{let i=t(r);return $(i),()=>{}}),e(),ct(t)}function ge(e,t,r,n=[]){switch(e._x_bindings||(e._x_bindings=T({})),e._x_bindings[t]=r,t=n.includes("camel")?si(t):t,t){case"value":Qn(e,r);break;case"style":ti(e,r);break;case"class":ei(e,r);break;case"selected":case"checked":ri(e,t,r);break;default:Pr(e,t,r);break}}function Qn(e,t){if(Ot(e))e.attributes.value===void 0&&(e.value=t),window.fromModel&&(typeof t=="boolean"?e.checked=xe(e.value)===t:e.checked=Dr(e.value,t));else if(ze(e))Number.isInteger(t)?e.value=t:!Array.isArray(t)&&typeof t!="boolean"&&![null,void 0].includes(t)?e.value=String(t):Array.isArray(t)?e.checked=t.some(r=>Dr(r,e.value)):e.checked=!!t;else if(e.tagName==="SELECT")oi(e,t);else{if(e.value===t)return;e.value=t===void 0?"":t}}function ei(e,t){e._x_undoAddedClasses&&e._x_undoAddedClasses(),e._x_undoAddedClasses=me(e,t)}function ti(e,t){e._x_undoAddedStyles&&e._x_undoAddedStyles(),e._x_undoAddedStyles=X(e,t)}function ri(e,t,r){Pr(e,t,r),ii(e,t,r)}function Pr(e,t,r){[null,void 0,!1].includes(r)&&ci(t)?e.removeAttribute(t):(Ir(t)&&(r=t),ni(e,t,r))}function ni(e,t,r){e.getAttribute(t)!=r&&e.setAttribute(t,r)}function ii(e,t,r){e[t]!==r&&(e[t]=r)}function oi(e,t){let r=[].concat(t).map(n=>n+"");Array.from(e.options).forEach(n=>{n.selected=r.includes(n.value)})}function si(e){return e.toLowerCase().replace(/-(\w)/g,(t,r)=>r.toUpperCase())}function Dr(e,t){return e==t}function xe(e){return[1,"1","true","on","yes",!0].includes(e)?!0:[0,"0","false","off","no",!1].includes(e)?!1:e?Boolean(e):null}var ai=new Set(["allowfullscreen","async","autofocus","autoplay","checked","controls","default","defer","disabled","formnovalidate","inert","ismap","itemscope","loop","multiple","muted","nomodule","novalidate","open","playsinline","readonly","required","reversed","selected","shadowrootclonable","shadowrootdelegatesfocus","shadowrootserializable"]);function Ir(e){return ai.has(e)}function ci(e){return!["aria-pressed","aria-checked","aria-expanded","aria-selected"].includes(e)}function Lr(e,t,r){return e._x_bindings&&e._x_bindings[t]!==void 0?e._x_bindings[t]:jr(e,t,r)}function $r(e,t,r,n=!0){if(e._x_bindings&&e._x_bindings[t]!==void 0)return e._x_bindings[t];if(e._x_inlineBindings&&e._x_inlineBindings[t]!==void 0){let i=e._x_inlineBindings[t];return i.extract=n,ke(()=>R(e,i.expression))}return jr(e,t,r)}function jr(e,t,r){let n=e.getAttribute(t);return n===null?typeof r=="function"?r():r:n===""?!0:Ir(t)?!![t,"true"].includes(n):n}function ze(e){return e.type==="checkbox"||e.localName==="ui-checkbox"||e.localName==="ui-switch"}function Ot(e){return e.type==="radio"||e.localName==="ui-radio"}function Ke(e,t){let r;return function(){let n=this,i=arguments,o=function(){r=null,e.apply(n,i)};clearTimeout(r),r=setTimeout(o,t)}}function He(e,t){let r;return function(){let n=this,i=arguments;r||(e.apply(n,i),r=!0,setTimeout(()=>r=!1,t))}}function Ve({get:e,set:t},{get:r,set:n}){let i=!0,o,s,a=N(()=>{let c=e(),l=r();if(i)n(Ct(c)),i=!1;else{let u=JSON.stringify(c),p=JSON.stringify(l);u!==o?n(Ct(c)):u!==p&&t(Ct(l))}o=JSON.stringify(e()),s=JSON.stringify(r())});return()=>{$(a)}}function Ct(e){return typeof e=="object"?JSON.parse(JSON.stringify(e)):e}function Fr(e){(Array.isArray(e)?e:[e]).forEach(r=>r(K))}var Z={},Br=!1;function zr(e,t){if(Br||(Z=T(Z),Br=!0),t===void 0)return Z[e];Z[e]=t,Te(Z[e]),typeof t=="object"&&t!==null&&t.hasOwnProperty("init")&&typeof t.init=="function"&&Z[e].init()}function Kr(){return Z}var Hr={};function Vr(e,t){let r=typeof t!="function"?()=>t:t;return e instanceof Element?Tt(e,r()):(Hr[e]=r,()=>{})}function qr(e){return Object.entries(Hr).forEach(([t,r])=>{Object.defineProperty(e,t,{get(){return(...n)=>r(...n)}})}),e}function Tt(e,t,r){let n=[];for(;n.length;)n.pop()();let i=Object.entries(t).map(([s,a])=>({name:s,value:a})),o=Et(i);return i=i.map(s=>o.find(a=>a.name===s.name)?{name:`x-bind:${s.name}`,value:`"${s.value}"`}:s),pe(e,i,r).map(s=>{n.push(s.runCleanups),s()}),()=>{for(;n.length;)n.pop()()}}var Ur={};function Wr(e,t){Ur[e]=t}function Gr(e,t){return Object.entries(Ur).forEach(([r,n])=>{Object.defineProperty(e,r,{get(){return(...i)=>n.bind(t)(...i)},enumerable:!1})}),e}var li={get reactive(){return T},get release(){return $},get effect(){return N},get raw(){return at},version:"3.15.1",flushAndStopDeferringMutations:nr,dontAutoEvaluateFunctions:ke,disableEffectScheduling:Gt,startObservingMutations:ue,stopObservingMutations:dt,setReactivityEngine:Jt,onAttributeRemoved:Oe,onAttributesAdded:Ae,closestDataStack:B,skipDuringClone:A,onlyDuringClone:Tr,addRootSelector:Le,addInitSelector:$e,interceptClone:H,addScopeToNode:k,deferMutations:rr,mapAttributes:ne,evaluateLater:x,interceptInit:vr,setEvaluator:ar,mergeProxies:z,extractProp:$r,findClosest:j,onElRemoved:te,closestRoot:Y,destroyTree:P,interceptor:Re,transition:Fe,setStyles:X,mutateDom:m,directive:d,entangle:Ve,throttle:He,debounce:Ke,evaluate:R,initTree:S,nextTick:ie,prefixed:C,prefix:cr,plugin:Fr,magic:y,store:zr,start:gr,clone:Nr,cloneNode:Mr,bound:Lr,$data:Ce,watch:ve,walk:D,data:Wr,bind:Vr},K=li;function Rt(e,t){let r=Object.create(null),n=e.split(",");for(let i=0;i<n.length;i++)r[n[i]]=!0;return t?i=>!!r[i.toLowerCase()]:i=>!!r[i]}var ui="itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly";var Ls=Rt(ui+",async,autofocus,autoplay,controls,default,defer,disabled,hidden,loop,open,required,reversed,scoped,seamless,checked,muted,multiple,selected");var Jr=Object.freeze({}),$s=Object.freeze([]);var fi=Object.prototype.hasOwnProperty,ye=(e,t)=>fi.call(e,t),V=Array.isArray,oe=e=>Yr(e)==="[object Map]";var di=e=>typeof e=="string",qe=e=>typeof e=="symbol",be=e=>e!==null&&typeof e=="object";var pi=Object.prototype.toString,Yr=e=>pi.call(e),Mt=e=>Yr(e).slice(8,-1);var Ue=e=>di(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e;var We=e=>{let t=Object.create(null);return r=>t[r]||(t[r]=e(r))},mi=/-(\w)/g,js=We(e=>e.replace(mi,(t,r)=>r?r.toUpperCase():"")),hi=/\B([A-Z])/g,Fs=We(e=>e.replace(hi,"-$1").toLowerCase()),Nt=We(e=>e.charAt(0).toUpperCase()+e.slice(1)),Bs=We(e=>e?`on${Nt(e)}`:""),kt=(e,t)=>e!==t&&(e===e||t===t);var Dt=new WeakMap,we=[],L,Q=Symbol("iterate"),Pt=Symbol("Map key iterate");function _i(e){return e&&e._isEffect===!0}function rn(e,t=Jr){_i(e)&&(e=e.raw);let r=xi(e,t);return t.lazy||r(),r}function nn(e){e.active&&(on(e),e.options.onStop&&e.options.onStop(),e.active=!1)}var gi=0;function xi(e,t){let r=function(){if(!r.active)return e();if(!we.includes(r)){on(r);try{return bi(),we.push(r),L=r,e()}finally{we.pop(),sn(),L=we[we.length-1]}}};return r.id=gi++,r.allowRecurse=!!t.allowRecurse,r._isEffect=!0,r.active=!0,r.raw=e,r.deps=[],r.options=t,r}function on(e){let{deps:t}=e;if(t.length){for(let r=0;r<t.length;r++)t[r].delete(e);t.length=0}}var se=!0,Lt=[];function yi(){Lt.push(se),se=!1}function bi(){Lt.push(se),se=!0}function sn(){let e=Lt.pop();se=e===void 0?!0:e}function M(e,t,r){if(!se||L===void 0)return;let n=Dt.get(e);n||Dt.set(e,n=new Map);let i=n.get(r);i||n.set(r,i=new Set),i.has(L)||(i.add(L),L.deps.push(i),L.options.onTrack&&L.options.onTrack({effect:L,target:e,type:t,key:r}))}function U(e,t,r,n,i,o){let s=Dt.get(e);if(!s)return;let a=new Set,c=u=>{u&&u.forEach(p=>{(p!==L||p.allowRecurse)&&a.add(p)})};if(t==="clear")s.forEach(c);else if(r==="length"&&V(e))s.forEach((u,p)=>{(p==="length"||p>=n)&&c(u)});else switch(r!==void 0&&c(s.get(r)),t){case"add":V(e)?Ue(r)&&c(s.get("length")):(c(s.get(Q)),oe(e)&&c(s.get(Pt)));break;case"delete":V(e)||(c(s.get(Q)),oe(e)&&c(s.get(Pt)));break;case"set":oe(e)&&c(s.get(Q));break}let l=u=>{u.options.onTrigger&&u.options.onTrigger({effect:u,target:e,key:r,type:t,newValue:n,oldValue:i,oldTarget:o}),u.options.scheduler?u.options.scheduler(u):u()};a.forEach(l)}var wi=Rt("__proto__,__v_isRef,__isVue"),an=new Set(Object.getOwnPropertyNames(Symbol).map(e=>Symbol[e]).filter(qe)),Ei=cn();var vi=cn(!0);var Xr=Si();function Si(){let e={};return["includes","indexOf","lastIndexOf"].forEach(t=>{e[t]=function(...r){let n=_(this);for(let o=0,s=this.length;o<s;o++)M(n,"get",o+"");let i=n[t](...r);return i===-1||i===!1?n[t](...r.map(_)):i}}),["push","pop","shift","unshift","splice"].forEach(t=>{e[t]=function(...r){yi();let n=_(this)[t].apply(this,r);return sn(),n}}),e}function cn(e=!1,t=!1){return function(n,i,o){if(i==="__v_isReactive")return!e;if(i==="__v_isReadonly")return e;if(i==="__v_raw"&&o===(e?t?Bi:dn:t?Fi:fn).get(n))return n;let s=V(n);if(!e&&s&&ye(Xr,i))return Reflect.get(Xr,i,o);let a=Reflect.get(n,i,o);return(qe(i)?an.has(i):wi(i))||(e||M(n,"get",i),t)?a:It(a)?!s||!Ue(i)?a.value:a:be(a)?e?pn(a):et(a):a}}var Ai=Oi();function Oi(e=!1){return function(r,n,i,o){let s=r[n];if(!e&&(i=_(i),s=_(s),!V(r)&&It(s)&&!It(i)))return s.value=i,!0;let a=V(r)&&Ue(n)?Number(n)<r.length:ye(r,n),c=Reflect.set(r,n,i,o);return r===_(o)&&(a?kt(i,s)&&U(r,"set",n,i,s):U(r,"add",n,i)),c}}function Ci(e,t){let r=ye(e,t),n=e[t],i=Reflect.deleteProperty(e,t);return i&&r&&U(e,"delete",t,void 0,n),i}function Ti(e,t){let r=Reflect.has(e,t);return(!qe(t)||!an.has(t))&&M(e,"has",t),r}function Ri(e){return M(e,"iterate",V(e)?"length":Q),Reflect.ownKeys(e)}var Mi={get:Ei,set:Ai,deleteProperty:Ci,has:Ti,ownKeys:Ri},Ni={get:vi,set(e,t){return console.warn(`Set operation on key "${String(t)}" failed: target is readonly.`,e),!0},deleteProperty(e,t){return console.warn(`Delete operation on key "${String(t)}" failed: target is readonly.`,e),!0}};var $t=e=>be(e)?et(e):e,jt=e=>be(e)?pn(e):e,Ft=e=>e,Qe=e=>Reflect.getPrototypeOf(e);function Ge(e,t,r=!1,n=!1){e=e.__v_raw;let i=_(e),o=_(t);t!==o&&!r&&M(i,"get",t),!r&&M(i,"get",o);let{has:s}=Qe(i),a=n?Ft:r?jt:$t;if(s.call(i,t))return a(e.get(t));if(s.call(i,o))return a(e.get(o));e!==i&&e.get(t)}function Je(e,t=!1){let r=this.__v_raw,n=_(r),i=_(e);return e!==i&&!t&&M(n,"has",e),!t&&M(n,"has",i),e===i?r.has(e):r.has(e)||r.has(i)}function Ye(e,t=!1){return e=e.__v_raw,!t&&M(_(e),"iterate",Q),Reflect.get(e,"size",e)}function Zr(e){e=_(e);let t=_(this);return Qe(t).has.call(t,e)||(t.add(e),U(t,"add",e,e)),this}function Qr(e,t){t=_(t);let r=_(this),{has:n,get:i}=Qe(r),o=n.call(r,e);o?un(r,n,e):(e=_(e),o=n.call(r,e));let s=i.call(r,e);return r.set(e,t),o?kt(t,s)&&U(r,"set",e,t,s):U(r,"add",e,t),this}function en(e){let t=_(this),{has:r,get:n}=Qe(t),i=r.call(t,e);i?un(t,r,e):(e=_(e),i=r.call(t,e));let o=n?n.call(t,e):void 0,s=t.delete(e);return i&&U(t,"delete",e,void 0,o),s}function tn(){let e=_(this),t=e.size!==0,r=oe(e)?new Map(e):new Set(e),n=e.clear();return t&&U(e,"clear",void 0,void 0,r),n}function Xe(e,t){return function(n,i){let o=this,s=o.__v_raw,a=_(s),c=t?Ft:e?jt:$t;return!e&&M(a,"iterate",Q),s.forEach((l,u)=>n.call(i,c(l),c(u),o))}}function Ze(e,t,r){return function(...n){let i=this.__v_raw,o=_(i),s=oe(o),a=e==="entries"||e===Symbol.iterator&&s,c=e==="keys"&&s,l=i[e](...n),u=r?Ft:t?jt:$t;return!t&&M(o,"iterate",c?Pt:Q),{next(){let{value:p,done:h}=l.next();return h?{value:p,done:h}:{value:a?[u(p[0]),u(p[1])]:u(p),done:h}},[Symbol.iterator](){return this}}}}function q(e){return function(...t){{let r=t[0]?`on key "${t[0]}" `:"";console.warn(`${Nt(e)} operation ${r}failed: target is readonly.`,_(this))}return e==="delete"?!1:this}}function ki(){let e={get(o){return Ge(this,o)},get size(){return Ye(this)},has:Je,add:Zr,set:Qr,delete:en,clear:tn,forEach:Xe(!1,!1)},t={get(o){return Ge(this,o,!1,!0)},get size(){return Ye(this)},has:Je,add:Zr,set:Qr,delete:en,clear:tn,forEach:Xe(!1,!0)},r={get(o){return Ge(this,o,!0)},get size(){return Ye(this,!0)},has(o){return Je.call(this,o,!0)},add:q("add"),set:q("set"),delete:q("delete"),clear:q("clear"),forEach:Xe(!0,!1)},n={get(o){return Ge(this,o,!0,!0)},get size(){return Ye(this,!0)},has(o){return Je.call(this,o,!0)},add:q("add"),set:q("set"),delete:q("delete"),clear:q("clear"),forEach:Xe(!0,!0)};return["keys","values","entries",Symbol.iterator].forEach(o=>{e[o]=Ze(o,!1,!1),r[o]=Ze(o,!0,!1),t[o]=Ze(o,!1,!0),n[o]=Ze(o,!0,!0)}),[e,r,t,n]}var[Di,Pi,Ii,Li]=ki();function ln(e,t){let r=t?e?Li:Ii:e?Pi:Di;return(n,i,o)=>i==="__v_isReactive"?!e:i==="__v_isReadonly"?e:i==="__v_raw"?n:Reflect.get(ye(r,i)&&i in n?r:n,i,o)}var $i={get:ln(!1,!1)};var ji={get:ln(!0,!1)};function un(e,t,r){let n=_(r);if(n!==r&&t.call(e,n)){let i=Mt(e);console.warn(`Reactive ${i} contains both the raw and reactive versions of the same object${i==="Map"?" as keys":""}, which can lead to inconsistencies. Avoid differentiating between the raw and reactive versions of an object and only use the reactive version if possible.`)}}var fn=new WeakMap,Fi=new WeakMap,dn=new WeakMap,Bi=new WeakMap;function zi(e){switch(e){case"Object":case"Array":return 1;case"Map":case"Set":case"WeakMap":case"WeakSet":return 2;default:return 0}}function Ki(e){return e.__v_skip||!Object.isExtensible(e)?0:zi(Mt(e))}function et(e){return e&&e.__v_isReadonly?e:mn(e,!1,Mi,$i,fn)}function pn(e){return mn(e,!0,Ni,ji,dn)}function mn(e,t,r,n,i){if(!be(e))return console.warn(`value cannot be made reactive: ${String(e)}`),e;if(e.__v_raw&&!(t&&e.__v_isReactive))return e;let o=i.get(e);if(o)return o;let s=Ki(e);if(s===0)return e;let a=new Proxy(e,s===2?n:r);return i.set(e,a),a}function _(e){return e&&_(e.__v_raw)||e}function It(e){return Boolean(e&&e.__v_isRef===!0)}y("nextTick",()=>ie);y("dispatch",e=>J.bind(J,e));y("watch",(e,{evaluateLater:t,cleanup:r})=>(n,i)=>{let o=t(n),a=ve(()=>{let c;return o(l=>c=l),c},i);r(a)});y("store",Kr);y("data",e=>Ce(e));y("root",e=>Y(e));y("refs",e=>(e._x_refs_proxy||(e._x_refs_proxy=z(Hi(e))),e._x_refs_proxy));function Hi(e){let t=[];return j(e,r=>{r._x_refs&&t.push(r._x_refs)}),t}var Bt={};function zt(e){return Bt[e]||(Bt[e]=0),++Bt[e]}function hn(e,t){return j(e,r=>{if(r._x_ids&&r._x_ids[t])return!0})}function _n(e,t){e._x_ids||(e._x_ids={}),e._x_ids[t]||(e._x_ids[t]=zt(t))}y("id",(e,{cleanup:t})=>(r,n=null)=>{let i=`${r}${n?`-${n}`:""}`;return Vi(e,i,t,()=>{let o=hn(e,r),s=o?o._x_ids[r]:zt(r);return n?`${r}-${s}-${n}`:`${r}-${s}`})});H((e,t)=>{e._x_id&&(t._x_id=e._x_id)});function Vi(e,t,r,n){if(e._x_id||(e._x_id={}),e._x_id[t])return e._x_id[t];let i=n();return e._x_id[t]=i,r(()=>{delete e._x_id[t]}),i}y("el",e=>e);gn("Focus","focus","focus");gn("Persist","persist","persist");function gn(e,t,r){y(t,n=>E(`You can't use [$${t}] without first installing the "${e}" plugin here: https://alpinejs.dev/plugins/${r}`,n))}d("modelable",(e,{expression:t},{effect:r,evaluateLater:n,cleanup:i})=>{let o=n(t),s=()=>{let u;return o(p=>u=p),u},a=n(`${t} = __placeholder`),c=u=>a(()=>{},{scope:{__placeholder:u}}),l=s();c(l),queueMicrotask(()=>{if(!e._x_model)return;e._x_removeModelListeners.default();let u=e._x_model.get,p=e._x_model.set,h=Ve({get(){return u()},set(w){p(w)}},{get(){return s()},set(w){c(w)}});i(h)})});d("teleport",(e,{modifiers:t,expression:r},{cleanup:n})=>{e.tagName.toLowerCase()!=="template"&&E("x-teleport can only be used on a <template> tag",e);let i=xn(r),o=e.content.cloneNode(!0).firstElementChild;e._x_teleport=o,o._x_teleportBack=e,e.setAttribute("data-teleport-template",!0),o.setAttribute("data-teleport-target",!0),e._x_forwardEvents&&e._x_forwardEvents.forEach(a=>{o.addEventListener(a,c=>{c.stopPropagation(),e.dispatchEvent(new c.constructor(c.type,c))})}),k(o,{},e);let s=(a,c,l)=>{l.includes("prepend")?c.parentNode.insertBefore(a,c):l.includes("append")?c.parentNode.insertBefore(a,c.nextSibling):c.appendChild(a)};m(()=>{s(o,i,t),A(()=>{S(o)})()}),e._x_teleportPutBack=()=>{let a=xn(r);m(()=>{s(e._x_teleport,a,t)})},n(()=>m(()=>{o.remove(),P(o)}))});var qi=document.createElement("div");function xn(e){let t=A(()=>document.querySelector(e),()=>qi)();return t||E(`Cannot find x-teleport element for selector: "${e}"`),t}var yn=()=>{};yn.inline=(e,{modifiers:t},{cleanup:r})=>{t.includes("self")?e._x_ignoreSelf=!0:e._x_ignore=!0,r(()=>{t.includes("self")?delete e._x_ignoreSelf:delete e._x_ignore})};d("ignore",yn);d("effect",A((e,{expression:t},{effect:r})=>{r(x(e,t))}));function ae(e,t,r,n){let i=e,o=c=>n(c),s={},a=(c,l)=>u=>l(c,u);if(r.includes("dot")&&(t=Ui(t)),r.includes("camel")&&(t=Wi(t)),r.includes("passive")&&(s.passive=!0),r.includes("capture")&&(s.capture=!0),r.includes("window")&&(i=window),r.includes("document")&&(i=document),r.includes("debounce")){let c=r[r.indexOf("debounce")+1]||"invalid-wait",l=tt(c.split("ms")[0])?Number(c.split("ms")[0]):250;o=Ke(o,l)}if(r.includes("throttle")){let c=r[r.indexOf("throttle")+1]||"invalid-wait",l=tt(c.split("ms")[0])?Number(c.split("ms")[0]):250;o=He(o,l)}return r.includes("prevent")&&(o=a(o,(c,l)=>{l.preventDefault(),c(l)})),r.includes("stop")&&(o=a(o,(c,l)=>{l.stopPropagation(),c(l)})),r.includes("once")&&(o=a(o,(c,l)=>{c(l),i.removeEventListener(t,o,s)})),(r.includes("away")||r.includes("outside"))&&(i=document,o=a(o,(c,l)=>{e.contains(l.target)||l.target.isConnected!==!1&&(e.offsetWidth<1&&e.offsetHeight<1||e._x_isShown!==!1&&c(l))})),r.includes("self")&&(o=a(o,(c,l)=>{l.target===e&&c(l)})),(Ji(t)||wn(t))&&(o=a(o,(c,l)=>{Yi(l,r)||c(l)})),i.addEventListener(t,o,s),()=>{i.removeEventListener(t,o,s)}}function Ui(e){return e.replace(/-/g,".")}function Wi(e){return e.toLowerCase().replace(/-(\w)/g,(t,r)=>r.toUpperCase())}function tt(e){return!Array.isArray(e)&&!isNaN(e)}function Gi(e){return[" ","_"].includes(e)?e:e.replace(/([a-z])([A-Z])/g,"$1-$2").replace(/[_\s]/,"-").toLowerCase()}function Ji(e){return["keydown","keyup"].includes(e)}function wn(e){return["contextmenu","click","mouse"].some(t=>e.includes(t))}function Yi(e,t){let r=t.filter(o=>!["window","document","prevent","stop","once","capture","self","away","outside","passive","preserve-scroll"].includes(o));if(r.includes("debounce")){let o=r.indexOf("debounce");r.splice(o,tt((r[o+1]||"invalid-wait").split("ms")[0])?2:1)}if(r.includes("throttle")){let o=r.indexOf("throttle");r.splice(o,tt((r[o+1]||"invalid-wait").split("ms")[0])?2:1)}if(r.length===0||r.length===1&&bn(e.key).includes(r[0]))return!1;let i=["ctrl","shift","alt","meta","cmd","super"].filter(o=>r.includes(o));return r=r.filter(o=>!i.includes(o)),!(i.length>0&&i.filter(s=>((s==="cmd"||s==="super")&&(s="meta"),e[`${s}Key`])).length===i.length&&(wn(e.type)||bn(e.key).includes(r[0])))}function bn(e){if(!e)return[];e=Gi(e);let t={ctrl:"control",slash:"/",space:" ",spacebar:" ",cmd:"meta",esc:"escape",up:"arrow-up",down:"arrow-down",left:"arrow-left",right:"arrow-right",period:".",comma:",",equal:"=",minus:"-",underscore:"_"};return t[e]=e,Object.keys(t).map(r=>{if(t[r]===e)return r}).filter(r=>r)}d("model",(e,{modifiers:t,expression:r},{effect:n,cleanup:i})=>{let o=e;t.includes("parent")&&(o=e.parentNode);let s=x(o,r),a;typeof r=="string"?a=x(o,`${r} = __placeholder`):typeof r=="function"&&typeof r()=="string"?a=x(o,`${r()} = __placeholder`):a=()=>{};let c=()=>{let h;return s(w=>h=w),En(h)?h.get():h},l=h=>{let w;s(F=>w=F),En(w)?w.set(h):a(()=>{},{scope:{__placeholder:h}})};typeof r=="string"&&e.type==="radio"&&m(()=>{e.hasAttribute("name")||e.setAttribute("name",r)});let u=e.tagName.toLowerCase()==="select"||["checkbox","radio"].includes(e.type)||t.includes("lazy")?"change":"input",p=I?()=>{}:ae(e,u,t,h=>{l(Kt(e,t,h,c()))});if(t.includes("fill")&&([void 0,null,""].includes(c())||ze(e)&&Array.isArray(c())||e.tagName.toLowerCase()==="select"&&e.multiple)&&l(Kt(e,t,{target:e},c())),e._x_removeModelListeners||(e._x_removeModelListeners={}),e._x_removeModelListeners.default=p,i(()=>e._x_removeModelListeners.default()),e.form){let h=ae(e.form,"reset",[],w=>{ie(()=>e._x_model&&e._x_model.set(Kt(e,t,{target:e},c())))});i(()=>h())}e._x_model={get(){return c()},set(h){l(h)}},e._x_forceModelUpdate=h=>{h===void 0&&typeof r=="string"&&r.match(/\./)&&(h=""),window.fromModel=!0,m(()=>ge(e,"value",h)),delete window.fromModel},n(()=>{let h=c();t.includes("unintrusive")&&document.activeElement.isSameNode(e)||e._x_forceModelUpdate(h)})});function Kt(e,t,r,n){return m(()=>{if(r instanceof CustomEvent&&r.detail!==void 0)return r.detail!==null&&r.detail!==void 0?r.detail:r.target.value;if(ze(e))if(Array.isArray(n)){let i=null;return t.includes("number")?i=Ht(r.target.value):t.includes("boolean")?i=xe(r.target.value):i=r.target.value,r.target.checked?n.includes(i)?n:n.concat([i]):n.filter(o=>!Xi(o,i))}else return r.target.checked;else{if(e.tagName.toLowerCase()==="select"&&e.multiple)return t.includes("number")?Array.from(r.target.selectedOptions).map(i=>{let o=i.value||i.text;return Ht(o)}):t.includes("boolean")?Array.from(r.target.selectedOptions).map(i=>{let o=i.value||i.text;return xe(o)}):Array.from(r.target.selectedOptions).map(i=>i.value||i.text);{let i;return Ot(e)?r.target.checked?i=r.target.value:i=n:i=r.target.value,t.includes("number")?Ht(i):t.includes("boolean")?xe(i):t.includes("trim")?i.trim():i}}})}function Ht(e){let t=e?parseFloat(e):null;return Zi(t)?t:e}function Xi(e,t){return e==t}function Zi(e){return!Array.isArray(e)&&!isNaN(e)}function En(e){return e!==null&&typeof e=="object"&&typeof e.get=="function"&&typeof e.set=="function"}d("cloak",e=>queueMicrotask(()=>m(()=>e.removeAttribute(C("cloak")))));$e(()=>`[${C("init")}]`);d("init",A((e,{expression:t},{evaluate:r})=>typeof t=="string"?!!t.trim()&&r(t,{},!1):r(t,{},!1)));d("text",(e,{expression:t},{effect:r,evaluateLater:n})=>{let i=n(t);r(()=>{i(o=>{m(()=>{e.textContent=o})})})});d("html",(e,{expression:t},{effect:r,evaluateLater:n})=>{let i=n(t);r(()=>{i(o=>{m(()=>{e.innerHTML=o,e._x_ignoreSelf=!0,S(e),delete e._x_ignoreSelf})})})});ne(Pe(":",Ie(C("bind:"))));var vn=(e,{value:t,modifiers:r,expression:n,original:i},{effect:o,cleanup:s})=>{if(!t){let c={};qr(c),x(e,n)(u=>{Tt(e,u,i)},{scope:c});return}if(t==="key")return Qi(e,n);if(e._x_inlineBindings&&e._x_inlineBindings[t]&&e._x_inlineBindings[t].extract)return;let a=x(e,n);o(()=>a(c=>{c===void 0&&typeof n=="string"&&n.match(/\./)&&(c=""),m(()=>ge(e,t,c,r))})),s(()=>{e._x_undoAddedClasses&&e._x_undoAddedClasses(),e._x_undoAddedStyles&&e._x_undoAddedStyles()})};vn.inline=(e,{value:t,modifiers:r,expression:n})=>{t&&(e._x_inlineBindings||(e._x_inlineBindings={}),e._x_inlineBindings[t]={expression:n,extract:!1})};d("bind",vn);function Qi(e,t){e._x_keyExpression=t}Le(()=>`[${C("data")}]`);d("data",(e,{expression:t},{cleanup:r})=>{if(eo(e))return;t=t===""?"{}":t;let n={};fe(n,e);let i={};Gr(i,n);let o=R(e,t,{scope:i});(o===void 0||o===!0)&&(o={}),fe(o,e);let s=T(o);Te(s);let a=k(e,s);s.init&&R(e,s.init),r(()=>{s.destroy&&R(e,s.destroy),a()})});H((e,t)=>{e._x_dataStack&&(t._x_dataStack=e._x_dataStack,t.setAttribute("data-has-alpine-state",!0))});function eo(e){return I?Be?!0:e.hasAttribute("data-has-alpine-state"):!1}d("show",(e,{modifiers:t,expression:r},{effect:n})=>{let i=x(e,r);e._x_doHide||(e._x_doHide=()=>{m(()=>{e.style.setProperty("display","none",t.includes("important")?"important":void 0)})}),e._x_doShow||(e._x_doShow=()=>{m(()=>{e.style.length===1&&e.style.display==="none"?e.removeAttribute("style"):e.style.removeProperty("display")})});let o=()=>{e._x_doHide(),e._x_isShown=!1},s=()=>{e._x_doShow(),e._x_isShown=!0},a=()=>setTimeout(s),c=he(p=>p?s():o(),p=>{typeof e._x_toggleAndCascadeWithTransitions=="function"?e._x_toggleAndCascadeWithTransitions(e,p,s,o):p?a():o()}),l,u=!0;n(()=>i(p=>{!u&&p===l||(t.includes("immediate")&&(p?a():o()),c(p),l=p,u=!1)}))});d("for",(e,{expression:t},{effect:r,cleanup:n})=>{let i=ro(t),o=x(e,i.items),s=x(e,e._x_keyExpression||"index");e._x_prevKeys=[],e._x_lookup={},r(()=>to(e,i,o,s)),n(()=>{Object.values(e._x_lookup).forEach(a=>m(()=>{P(a),a.remove()})),delete e._x_prevKeys,delete e._x_lookup})});function to(e,t,r,n){let i=s=>typeof s=="object"&&!Array.isArray(s),o=e;r(s=>{no(s)&&s>=0&&(s=Array.from(Array(s).keys(),f=>f+1)),s===void 0&&(s=[]);let a=e._x_lookup,c=e._x_prevKeys,l=[],u=[];if(i(s))s=Object.entries(s).map(([f,g])=>{let b=Sn(t,g,f,s);n(v=>{u.includes(v)&&E("Duplicate key on x-for",e),u.push(v)},{scope:{index:f,...b}}),l.push(b)});else for(let f=0;f<s.length;f++){let g=Sn(t,s[f],f,s);n(b=>{u.includes(b)&&E("Duplicate key on x-for",e),u.push(b)},{scope:{index:f,...g}}),l.push(g)}let p=[],h=[],w=[],F=[];for(let f=0;f<c.length;f++){let g=c[f];u.indexOf(g)===-1&&w.push(g)}c=c.filter(f=>!w.includes(f));let Ee="template";for(let f=0;f<u.length;f++){let g=u[f],b=c.indexOf(g);if(b===-1)c.splice(f,0,g),p.push([Ee,f]);else if(b!==f){let v=c.splice(f,1)[0],O=c.splice(b-1,1)[0];c.splice(f,0,O),c.splice(b,0,v),h.push([v,O])}else F.push(g);Ee=g}for(let f=0;f<w.length;f++){let g=w[f];g in a&&(m(()=>{P(a[g]),a[g].remove()}),delete a[g])}for(let f=0;f<h.length;f++){let[g,b]=h[f],v=a[g],O=a[b],ee=document.createElement("div");m(()=>{O||E('x-for ":key" is undefined or invalid',o,b,a),O.after(ee),v.after(O),O._x_currentIfEl&&O.after(O._x_currentIfEl),ee.before(v),v._x_currentIfEl&&v.after(v._x_currentIfEl),ee.remove()}),O._x_refreshXForScope(l[u.indexOf(b)])}for(let f=0;f<p.length;f++){let[g,b]=p[f],v=g==="template"?o:a[g];v._x_currentIfEl&&(v=v._x_currentIfEl);let O=l[b],ee=u[b],ce=document.importNode(o.content,!0).firstElementChild,qt=T(O);k(ce,qt,o),ce._x_refreshXForScope=On=>{Object.entries(On).forEach(([Cn,Tn])=>{qt[Cn]=Tn})},m(()=>{v.after(ce),A(()=>S(ce))()}),typeof ee=="object"&&E("x-for key cannot be an object, it must be a string or an integer",o),a[ee]=ce}for(let f=0;f<F.length;f++)a[F[f]]._x_refreshXForScope(l[u.indexOf(F[f])]);o._x_prevKeys=u})}function ro(e){let t=/,([^,\}\]]*)(?:,([^,\}\]]*))?$/,r=/^\s*\(|\)\s*$/g,n=/([\s\S]*?)\s+(?:in|of)\s+([\s\S]*)/,i=e.match(n);if(!i)return;let o={};o.items=i[2].trim();let s=i[1].replace(r,"").trim(),a=s.match(t);return a?(o.item=s.replace(t,"").trim(),o.index=a[1].trim(),a[2]&&(o.collection=a[2].trim())):o.item=s,o}function Sn(e,t,r,n){let i={};return/^\[.*\]$/.test(e.item)&&Array.isArray(t)?e.item.replace("[","").replace("]","").split(",").map(s=>s.trim()).forEach((s,a)=>{i[s]=t[a]}):/^\{.*\}$/.test(e.item)&&!Array.isArray(t)&&typeof t=="object"?e.item.replace("{","").replace("}","").split(",").map(s=>s.trim()).forEach(s=>{i[s]=t[s]}):i[e.item]=t,e.index&&(i[e.index]=r),e.collection&&(i[e.collection]=n),i}function no(e){return!Array.isArray(e)&&!isNaN(e)}function An(){}An.inline=(e,{expression:t},{cleanup:r})=>{let n=Y(e);n._x_refs||(n._x_refs={}),n._x_refs[t]=e,r(()=>delete n._x_refs[t])};d("ref",An);d("if",(e,{expression:t},{effect:r,cleanup:n})=>{e.tagName.toLowerCase()!=="template"&&E("x-if can only be used on a <template> tag",e);let i=x(e,t),o=()=>{if(e._x_currentIfEl)return e._x_currentIfEl;let a=e.content.cloneNode(!0).firstElementChild;return k(a,{},e),m(()=>{e.after(a),A(()=>S(a))()}),e._x_currentIfEl=a,e._x_undoIf=()=>{m(()=>{P(a),a.remove()}),delete e._x_currentIfEl},a},s=()=>{e._x_undoIf&&(e._x_undoIf(),delete e._x_undoIf)};r(()=>i(a=>{a?o():s()})),n(()=>e._x_undoIf&&e._x_undoIf())});d("id",(e,{expression:t},{evaluate:r})=>{r(t).forEach(i=>_n(e,i))});H((e,t)=>{e._x_ids&&(t._x_ids=e._x_ids)});ne(Pe("@",Ie(C("on:"))));d("on",A((e,{value:t,modifiers:r,expression:n},{cleanup:i})=>{let o=n?x(e,n):()=>{};e.tagName.toLowerCase()==="template"&&(e._x_forwardEvents||(e._x_forwardEvents=[]),e._x_forwardEvents.includes(t)||e._x_forwardEvents.push(t));let s=ae(e,t,r,a=>{o(()=>{},{scope:{$event:a},params:[a]})});i(()=>s())}));rt("Collapse","collapse","collapse");rt("Intersect","intersect","intersect");rt("Focus","trap","focus");rt("Mask","mask","mask");function rt(e,t,r){d(t,n=>E(`You can't use [x-${t}] without first installing the "${e}" plugin here: https://alpinejs.dev/plugins/${r}`,n))}K.setEvaluator(xt);K.setReactivityEngine({reactive:et,effect:rn,release:nn,raw:_});var Vt=K;window.Alpine=Vt;queueMicrotask(()=>{Vt.start()});})();
+1
templates/en-us/base.html
··· 17 17 <script src="/static/htmx.js"></script> 18 18 <script src="/static/loading-states.js"></script> 19 19 <script src="/static/sse.js"></script> 20 + <script defer src="/static/alpine.js"></script> 20 21 <script src="/static/site.js"></script> 21 22 {% block head %} 22 23 {% endblock %}
-4
templates/en-us/edit_event.bare.html
··· 1 - {% extends "en-us/bare.html" %} 2 - {% block content %} 3 - {% include 'en-us/edit_event.common.html' %} 4 - {% endblock %}
-154
templates/en-us/edit_event.common.html
··· 1 - {% from "form_include.html" import text_input %} 2 - <section class="section is-fullheight"> 3 - 4 - <div class="container"> 5 - <h1 class="title">Edit Event</h1> 6 - 7 - {% include 'en-us/create_event.partial.html' %} 8 - 9 - <div class="box has-background-info-light"> 10 - <h2 class="title is-4">Manage RSVPs</h2> 11 - <p class="mb-4">Accept RSVPs from attendees to confirm their attendance.</p> 12 - 13 - {% if rsvps and rsvps|length > 0 %} 14 - <table class="table is-fullwidth is-striped"> 15 - <thead> 16 - <tr> 17 - <th>Handle</th> 18 - <th>Status</th> 19 - <th>Action</th> 20 - </tr> 21 - </thead> 22 - <tbody> 23 - {% for rsvp in rsvps %} 24 - <tr> 25 - <td> 26 - {% if rsvp.handle %} 27 - <strong>@{{ rsvp.handle }}</strong> 28 - {% else %} 29 - <em>{{ rsvp.did }}</em> 30 - {% endif %} 31 - </td> 32 - <td> 33 - <span class="tag {% if rsvp.status == 'going' %}is-success{% elif rsvp.status == 'interested' %}is-info{% else %}is-light{% endif %}"> 34 - {{ rsvp.status }} 35 - </span> 36 - </td> 37 - <td> 38 - <form hx-post="/accept_rsvp" hx-target="#accept-rsvp-result-{{ loop.index }}" hx-swap="innerHTML" class="is-inline"> 39 - <input type="hidden" name="rsvp_aturi" value="{{ rsvp.rsvp_aturi }}" /> 40 - <button type="submit" class="button is-small is-primary" title="Accept this RSVP"> 41 - <span class="icon is-small"> 42 - <i class="fas fa-check"></i> 43 - </span> 44 - <span>Accept</span> 45 - </button> 46 - </form> 47 - <div id="accept-rsvp-result-{{ loop.index }}" class="mt-2"></div> 48 - </td> 49 - </tr> 50 - {% endfor %} 51 - </tbody> 52 - </table> 53 - {% else %} 54 - <p class="has-text-grey-light">No RSVPs yet.</p> 55 - {% endif %} 56 - 57 - <div id="accept-rsvp-result" class="mt-4"></div> 58 - </div> 59 - 60 - <div class="box has-background-primary-light"> 61 - <h2 class="title is-4">Export RSVPs</h2> 62 - <p class="mb-4">Download a CSV file of identities that have RSVP'd to the event.</p> 63 - <a href="/{{ handle_slug }}/{{ event_rkey }}/export-rsvps" class="button is-primary"> 64 - <span class="icon"> 65 - <i class="fas fa-download"></i> 66 - </span> 67 - <span>Export RSVPs</span> 68 - </a> 69 - </div> 70 - 71 - <div class="box has-background-link-light"> 72 - <h2 class="title is-4">Private Event Content</h2> 73 - <p class="mb-4">Add content that will only be visible to attendees who meet specific criteria. This content is stored off-protocol and not published to your PDS.</p> 74 - 75 - <form hx-post="/{{ handle_slug }}/{{ event_rkey }}/edit" hx-target="body" hx-swap="outerHTML"> 76 - <!-- Include all other form fields as hidden to preserve state --> 77 - <input type="hidden" name="build_state" value="Selected"> 78 - {% if build_event_form.name %} 79 - <input type="hidden" name="name" value="{{ build_event_form.name }}"> 80 - {% endif %} 81 - {% if build_event_form.description %} 82 - <input type="hidden" name="description" value="{{ build_event_form.description }}"> 83 - {% endif %} 84 - {% if build_event_form.status %} 85 - <input type="hidden" name="status" value="{{ build_event_form.status }}"> 86 - {% endif %} 87 - {% if build_event_form.mode %} 88 - <input type="hidden" name="mode" value="{{ build_event_form.mode }}"> 89 - {% endif %} 90 - {% if build_event_form.starts_at %} 91 - <input type="hidden" name="starts_at" value="{{ build_event_form.starts_at }}"> 92 - {% endif %} 93 - {% if build_event_form.ends_at %} 94 - <input type="hidden" name="ends_at" value="{{ build_event_form.ends_at }}"> 95 - {% endif %} 96 - {% if build_event_form.require_confirmed_email %} 97 - <input type="hidden" name="require_confirmed_email" value="true"> 98 - {% endif %} 99 - 100 - <div class="field"> 101 - <label class="label" for="privateContentTextarea">Private Content</label> 102 - <div class="control"> 103 - <textarea class="textarea" id="privateContentTextarea" name="private_content" 104 - maxlength="5000" rows="6" 105 - placeholder="Information that only specific attendees can see...">{% if build_event_form.private_content %}{{ build_event_form.private_content }}{% endif %}</textarea> 106 - </div> 107 - <p class="help">This content will only be shown to attendees who meet the selected criteria below.</p> 108 - </div> 109 - 110 - <div class="field"> 111 - <label class="label">Show to:</label> 112 - <div class="control"> 113 - <label class="checkbox is-block mb-2"> 114 - <input type="checkbox" name="private_content_criteria_going_confirmed" value="true" 115 - {% if build_event_form.private_content_criteria_going_confirmed %}checked{% endif %}> 116 - Attendees with "Going" status who have accepted their RSVP ticket 117 - </label> 118 - <label class="checkbox is-block mb-2"> 119 - <input type="checkbox" name="private_content_criteria_going" value="true" 120 - {% if build_event_form.private_content_criteria_going %}checked{% endif %}> 121 - All attendees with "Going" status 122 - </label> 123 - <label class="checkbox is-block"> 124 - <input type="checkbox" name="private_content_criteria_interested" value="true" 125 - {% if build_event_form.private_content_criteria_interested %}checked{% endif %}> 126 - Attendees with "Interested" status 127 - </label> 128 - </div> 129 - <p class="help mt-2">Select one or more criteria. Content will be shown if the viewer matches any selected criterion.</p> 130 - </div> 131 - 132 - <div class="field"> 133 - <div class="control"> 134 - <button type="submit" class="button is-link"> 135 - <span class="icon"> 136 - <i class="fas fa-save"></i> 137 - </span> 138 - <span>Save Private Content</span> 139 - </button> 140 - </div> 141 - </div> 142 - </form> 143 - </div> 144 - 145 - {% if delete_event_url %} 146 - {% include 'en-us/delete_event.partial.html' %} 147 - {% endif %} 148 - </div> 149 - </section> 150 - <script> 151 - function checkUserKeydown(event) { 152 - return event instanceof KeyboardEvent 153 - } 154 - </script>
-6
templates/en-us/edit_event.html
··· 1 - {% extends "en-us/base.html" %} 2 - {% block title %}Smoke Signal - Edit Event{% endblock %} 3 - {% block head %}{% endblock %} 4 - {% block content %} 5 - {% include 'en-us/edit_event.common.html' %} 6 - {% endblock %}
-1
templates/en-us/edit_event.partial.html
··· 1 - {% include 'en-us/create_event.partial.html' %}
+128
templates/en-us/manage_event.html
··· 1 + {% extends "en-us/base.html" %} 2 + {% block title %}Smoke Signal - Manage Event{% endblock %} 3 + {% block head %} 4 + <style> 5 + /* Tab navigation mobile optimization */ 6 + @media screen and (max-width: 768px) { 7 + .tabs.is-boxed ul { 8 + display: block; 9 + border: none; 10 + } 11 + 12 + .tabs.is-boxed li { 13 + display: block; 14 + width: 100%; 15 + border-bottom: 1px solid #dbdbdb; 16 + } 17 + 18 + .tabs.is-boxed li a { 19 + border: none; 20 + border-radius: 0; 21 + } 22 + } 23 + 24 + /* Sticky header */ 25 + .event-header-sticky { 26 + position: sticky; 27 + top: 0; 28 + z-index: 20; 29 + background: white; 30 + border-bottom: 1px solid #dbdbdb; 31 + padding: 1rem 0; 32 + margin-bottom: 1.5rem; 33 + } 34 + 35 + /* Tab loading indicator */ 36 + .tab-loading { 37 + opacity: 0; 38 + transition: opacity 200ms ease-in; 39 + } 40 + 41 + .tab-loading.htmx-request { 42 + opacity: 1; 43 + } 44 + 45 + /* Quick stats styling */ 46 + .quick-stats { 47 + display: flex; 48 + gap: 1.5rem; 49 + flex-wrap: wrap; 50 + margin-top: 0.75rem; 51 + } 52 + 53 + .quick-stat { 54 + display: flex; 55 + align-items: center; 56 + gap: 0.5rem; 57 + font-size: 0.875rem; 58 + } 59 + 60 + .quick-stat-value { 61 + font-weight: 600; 62 + font-size: 1.25rem; 63 + } 64 + 65 + @media screen and (max-width: 768px) { 66 + .quick-stats { 67 + gap: 1rem; 68 + } 69 + } 70 + </style> 71 + {% endblock %} 72 + {% block content %} 73 + <section class="section"> 74 + <div class="container"> 75 + {# Sticky Header with Event Info #} 76 + <div class="event-header-sticky"> 77 + <div class="level is-mobile"> 78 + <div class="level-left"> 79 + <div class="level-item"> 80 + <div> 81 + <h1 class="title is-4 mb-2">{{ event.name }}</h1> 82 + <div class="quick-stats"> 83 + {% if rsvp_counts %} 84 + <div class="quick-stat has-text-success"> 85 + <span class="icon"> 86 + <i class="fas fa-check-circle"></i> 87 + </span> 88 + <span class="quick-stat-value">{{ rsvp_counts.going }}</span> 89 + <span class="has-text-grey">Going</span> 90 + </div> 91 + <div class="quick-stat has-text-info"> 92 + <span class="icon"> 93 + <i class="fas fa-star"></i> 94 + </span> 95 + <span class="quick-stat-value">{{ rsvp_counts.interested }}</span> 96 + <span class="has-text-grey">Interested</span> 97 + </div> 98 + {% endif %} 99 + </div> 100 + </div> 101 + </div> 102 + </div> 103 + <div class="level-right"> 104 + <div class="level-item"> 105 + <a href="/{{ handle_slug }}/{{ event_rkey }}" class="button is-light is-small"> 106 + <span class="icon"> 107 + <i class="fas fa-eye"></i> 108 + </span> 109 + <span class="is-hidden-mobile">View Event</span> 110 + </a> 111 + </div> 112 + </div> 113 + </div> 114 + </div> 115 + 116 + {# Tabs and Content Container - swapped together for active state updates #} 117 + <div id="tabs-and-content"> 118 + {# Tab Navigation #} 119 + {% include 'en-us/manage_event_tabs.partial.html' %} 120 + 121 + {# Tab Content Container #} 122 + <div id="tab-content"> 123 + {% include tab_template %} 124 + </div> 125 + </div> 126 + </div> 127 + </section> 128 + {% endblock %}
+377
templates/en-us/manage_event_attendees_tab.html
··· 1 + {# Attendees Tab - Full RSVP Management #} 2 + <div class="tab-pane-attendees"> 3 + <div class="box has-background-info-light"> 4 + <h2 class="title is-5"> 5 + <span class="icon-text"> 6 + <span class="icon"> 7 + <i class="fas fa-info-circle"></i> 8 + </span> 9 + <span>About RSVP Acceptance</span> 10 + </span> 11 + </h2> 12 + <div class="content"> 13 + <p> 14 + This page allows you to manage RSVP acceptances for your event. You can: 15 + </p> 16 + <ul> 17 + <li><strong>Accept RSVPs</strong> individually or in bulk with optional custom metadata</li> 18 + <li><strong>Re-accept RSVPs</strong> to update metadata for already accepted attendees</li> 19 + <li><strong>Unaccept RSVPs</strong> to revoke an acceptance if needed</li> 20 + </ul> 21 + <p> 22 + When you accept an RSVP: 23 + </p> 24 + <ul> 25 + <li><strong>An acceptance record is published</strong> to your AT Protocol Personal Data Server (PDS)</li> 26 + <li><strong>The attendee is notified</strong> via email that their RSVP has been accepted</li> 27 + <li><strong>Custom metadata</strong> (if provided) is included in the acceptance record and visible to the attendee</li> 28 + </ul> 29 + <p> 30 + <strong>Use cases for custom metadata:</strong> Assign table numbers, meal preferences, 31 + check-in times, parking passes, or any event-specific information you want to share with confirmed attendees. 32 + </p> 33 + <p class="has-text-weight-bold"> 34 + Note: All metadata is published on-protocol and visible to attendees. Do not include private or sensitive information. 35 + </p> 36 + </div> 37 + </div> 38 + 39 + <div class="box has-background-primary-light"> 40 + <h2 class="title is-5"> 41 + <span class="icon-text"> 42 + <span class="icon"> 43 + <i class="fas fa-users"></i> 44 + </span> 45 + <span>RSVP Summary</span> 46 + </span> 47 + </h2> 48 + <div class="columns is-mobile"> 49 + <div class="column"> 50 + <div class="has-text-centered"> 51 + <p class="heading">Going</p> 52 + <p class="title is-4 has-text-success">{{ going_count }}</p> 53 + </div> 54 + </div> 55 + <div class="column"> 56 + <div class="has-text-centered"> 57 + <p class="heading">Interested</p> 58 + <p class="title is-4 has-text-info">{{ interested_count }}</p> 59 + </div> 60 + </div> 61 + <div class="column"> 62 + <div class="has-text-centered"> 63 + <p class="heading">Accepted</p> 64 + <p class="title is-4 has-text-link">{{ accepted_count }}</p> 65 + </div> 66 + </div> 67 + </div> 68 + </div> 69 + 70 + <form id="bulk-accept-form" hx-post="/bulk_accept_rsvps" hx-target="#bulk-accept-result" hx-swap="innerHTML"> 71 + <div class="box"> 72 + <h2 class="title is-5"> 73 + <span class="icon-text"> 74 + <span class="icon"> 75 + <i class="fas fa-tags"></i> 76 + </span> 77 + <span>Custom Metadata (Optional)</span> 78 + </span> 79 + </h2> 80 + <p class="mb-4"> 81 + Add custom key-value pairs to include with acceptance records. Examples: 82 + <code>table_number: 5</code>, <code>meal_preference: vegetarian</code>, 83 + <code>parking_pass: A123</code> 84 + </p> 85 + 86 + <div id="metadata-fields" class="mb-4"> 87 + <!-- Metadata fields will be added here dynamically --> 88 + </div> 89 + 90 + <button type="button" class="button is-light" onclick="addMetadataField()"> 91 + <span class="icon"> 92 + <i class="fas fa-plus"></i> 93 + </span> 94 + <span>Add Metadata Field</span> 95 + </button> 96 + </div> 97 + 98 + <div class="box"> 99 + <h2 class="title is-5"> 100 + <span class="icon-text"> 101 + <span class="icon"> 102 + <i class="fas fa-list-check"></i> 103 + </span> 104 + <span>Attendees</span> 105 + </span> 106 + </h2> 107 + 108 + {% if rsvps and rsvps|length > 0 %} 109 + <div class="table-container"> 110 + <table class="table is-fullwidth is-striped is-hoverable"> 111 + <thead> 112 + <tr> 113 + <th style="width: 50px;"> 114 + <label class="checkbox"> 115 + <input type="checkbox" id="select-all" onclick="toggleSelectAll(this)"> 116 + </label> 117 + </th> 118 + <th>Handle</th> 119 + <th>Status</th> 120 + <th>Accepted</th> 121 + <th style="width: 200px;">Actions</th> 122 + </tr> 123 + </thead> 124 + <tbody> 125 + {% for rsvp in rsvps %} 126 + <tr> 127 + <td> 128 + <label class="checkbox"> 129 + <input type="checkbox" 130 + name="rsvp_aturis" 131 + value="{{ rsvp.rsvp_aturi }}" 132 + class="rsvp-checkbox" 133 + onchange="updateSelectionCount()"> 134 + </label> 135 + </td> 136 + <td> 137 + {% if rsvp.handle %} 138 + <strong>@{{ rsvp.handle }}</strong> 139 + {% else %} 140 + <em class="has-text-grey">{{ rsvp.did }}</em> 141 + {% endif %} 142 + </td> 143 + <td> 144 + <span class="tag {% if rsvp.status == 'going' %}is-success{% elif rsvp.status == 'interested' %}is-info{% else %}is-light{% endif %}"> 145 + {{ rsvp.status }} 146 + </span> 147 + </td> 148 + <td> 149 + {% if rsvp.is_accepted %} 150 + <span class="icon has-text-success" title="Accepted"> 151 + <i class="fas fa-check-circle"></i> 152 + </span> 153 + {% else %} 154 + <span class="icon has-text-grey-light" title="Not accepted"> 155 + <i class="far fa-circle"></i> 156 + </span> 157 + {% endif %} 158 + </td> 159 + <td> 160 + {% if rsvp.is_accepted %} 161 + <div class="buttons"> 162 + <button type="button" 163 + hx-post="/unaccept_rsvp" 164 + hx-target="#bulk-accept-result" 165 + hx-swap="innerHTML" 166 + hx-vals='{"rsvp_aturi": "{{ rsvp.rsvp_aturi }}"}' 167 + class="button is-small is-warning"> 168 + <span class="icon"> 169 + <i class="fas fa-times"></i> 170 + </span> 171 + <span>Unaccept</span> 172 + </button> 173 + <button type="button" class="button is-small is-info" onclick="reAcceptSingle('{{ rsvp.rsvp_aturi }}', '{{ rsvp.handle if rsvp.handle else rsvp.did }}')"> 174 + <span class="icon"> 175 + <i class="fas fa-edit"></i> 176 + </span> 177 + <span>Re-accept</span> 178 + </button> 179 + </div> 180 + {% else %} 181 + <span class="has-text-grey-light is-size-7">N/A</span> 182 + {% endif %} 183 + </td> 184 + </tr> 185 + {% endfor %} 186 + </tbody> 187 + </table> 188 + </div> 189 + 190 + <div class="buttons mt-4"> 191 + <button type="button" 192 + class="button is-primary" 193 + onclick="acceptSelected()" 194 + id="accept-selected-btn"> 195 + <span class="icon"> 196 + <i class="fas fa-check"></i> 197 + </span> 198 + <span>Accept Selected (<span id="selection-count">0</span>)</span> 199 + </button> 200 + 201 + <button type="button" 202 + class="button is-success" 203 + onclick="acceptAllGoing()" 204 + {% if going_count == 0 %}disabled{% endif %}> 205 + <span class="icon"> 206 + <i class="fas fa-check-double"></i> 207 + </span> 208 + <span>Accept All Going ({{ going_count }})</span> 209 + </button> 210 + </div> 211 + 212 + {% else %} 213 + <p class="has-text-grey-light">No RSVPs yet.</p> 214 + {% endif %} 215 + 216 + <div id="bulk-accept-result" class="mt-4"></div> 217 + </div> 218 + </form> 219 + </div> 220 + 221 + <script> 222 + let metadataFieldCount = 0; 223 + 224 + function addMetadataField() { 225 + const container = document.getElementById('metadata-fields'); 226 + const fieldId = metadataFieldCount++; 227 + 228 + const fieldHtml = ` 229 + <div class="field is-horizontal" id="metadata-field-${fieldId}"> 230 + <div class="field-body"> 231 + <div class="field"> 232 + <div class="control"> 233 + <input class="input" 234 + type="text" 235 + name="metadata_key_${fieldId}" 236 + placeholder="Key (e.g., table_number)" 237 + maxlength="100"> 238 + </div> 239 + </div> 240 + <div class="field"> 241 + <div class="control"> 242 + <input class="input" 243 + type="text" 244 + name="metadata_value_${fieldId}" 245 + placeholder="Value (e.g., 5)" 246 + maxlength="500"> 247 + </div> 248 + </div> 249 + <div class="field is-narrow"> 250 + <div class="control"> 251 + <button type="button" 252 + class="button is-danger" 253 + onclick="removeMetadataField(${fieldId})" 254 + title="Remove this field"> 255 + <span class="icon"> 256 + <i class="fas fa-times"></i> 257 + </span> 258 + </button> 259 + </div> 260 + </div> 261 + </div> 262 + </div> 263 + `; 264 + 265 + container.insertAdjacentHTML('beforeend', fieldHtml); 266 + } 267 + 268 + function removeMetadataField(fieldId) { 269 + const field = document.getElementById(`metadata-field-${fieldId}`); 270 + if (field) { 271 + field.remove(); 272 + } 273 + } 274 + 275 + function toggleSelectAll(checkbox) { 276 + const checkboxes = document.querySelectorAll('.rsvp-checkbox'); 277 + checkboxes.forEach(cb => { 278 + cb.checked = checkbox.checked; 279 + }); 280 + updateSelectionCount(); 281 + } 282 + 283 + function updateSelectionCount() { 284 + const checkedCount = document.querySelectorAll('.rsvp-checkbox:checked').length; 285 + document.getElementById('selection-count').textContent = checkedCount; 286 + 287 + const selectAllCheckbox = document.getElementById('select-all'); 288 + const totalCheckboxes = document.querySelectorAll('.rsvp-checkbox').length; 289 + selectAllCheckbox.checked = checkedCount === totalCheckboxes && totalCheckboxes > 0; 290 + } 291 + 292 + function acceptSelected() { 293 + const checkedBoxes = document.querySelectorAll('.rsvp-checkbox:checked'); 294 + if (checkedBoxes.length === 0) { 295 + alert('Please select at least one RSVP to accept.'); 296 + return; 297 + } 298 + 299 + // Set action to empty (will use the selected checkboxes) 300 + const form = document.getElementById('bulk-accept-form'); 301 + 302 + // Remove any existing action input 303 + const existingAction = form.querySelector('input[name="action"]'); 304 + if (existingAction) { 305 + existingAction.remove(); 306 + } 307 + 308 + // Submit the form 309 + htmx.trigger(form, 'submit'); 310 + } 311 + 312 + function acceptAllGoing() { 313 + // Add a hidden input to indicate we want to accept all going 314 + const form = document.getElementById('bulk-accept-form'); 315 + 316 + // Remove any existing action input 317 + const existingAction = form.querySelector('input[name="action"]'); 318 + if (existingAction) { 319 + existingAction.remove(); 320 + } 321 + 322 + // Add action=all_going 323 + const actionInput = document.createElement('input'); 324 + actionInput.type = 'hidden'; 325 + actionInput.name = 'action'; 326 + actionInput.value = 'all_going'; 327 + form.appendChild(actionInput); 328 + 329 + // We still need at least one RSVP ATURI to determine the event 330 + // Add the first "going" RSVP's ATURI 331 + const firstGoingCheckbox = document.querySelector('.rsvp-checkbox'); 332 + if (firstGoingCheckbox) { 333 + const eventRsvp = document.createElement('input'); 334 + eventRsvp.type = 'hidden'; 335 + eventRsvp.name = 'rsvp_aturis'; 336 + eventRsvp.value = firstGoingCheckbox.value; 337 + form.appendChild(eventRsvp); 338 + } 339 + 340 + // Submit the form 341 + htmx.trigger(form, 'submit'); 342 + } 343 + 344 + function reAcceptSingle(rsvpAturi, handleOrDid) { 345 + if (!confirm(`Re-accept RSVP for ${handleOrDid}? You can update the metadata below before accepting.`)) { 346 + return; 347 + } 348 + 349 + // Uncheck all checkboxes 350 + const checkboxes = document.querySelectorAll('.rsvp-checkbox'); 351 + checkboxes.forEach(cb => cb.checked = false); 352 + 353 + // Check only the selected RSVP 354 + const targetCheckbox = document.querySelector(`.rsvp-checkbox[value="${rsvpAturi}"]`); 355 + if (targetCheckbox) { 356 + targetCheckbox.checked = true; 357 + } 358 + 359 + updateSelectionCount(); 360 + 361 + // Scroll to metadata section 362 + const metadataSection = document.querySelector('#metadata-fields').parentElement; 363 + metadataSection.scrollIntoView({ behavior: 'smooth', block: 'start' }); 364 + 365 + // Briefly highlight the metadata section 366 + metadataSection.classList.add('has-background-warning-light'); 367 + setTimeout(() => { 368 + metadataSection.classList.remove('has-background-warning-light'); 369 + }, 2000); 370 + } 371 + 372 + // Initialize 373 + document.addEventListener('DOMContentLoaded', function() { 374 + // Add one metadata field by default 375 + addMetadataField(); 376 + }); 377 + </script>
+155
templates/en-us/manage_event_content_tab.html
··· 1 + {# Private Content Tab - Manage attendee-only content #} 2 + <div class="tab-pane-content"> 3 + {% if content_saved %} 4 + <article class="message is-success mb-5"> 5 + <div class="message-header"> 6 + <p>Private Content Saved!</p> 7 + </div> 8 + <div class="message-body"> 9 + <p>Your private event content has been updated successfully.</p> 10 + </div> 11 + </article> 12 + {% endif %} 13 + 14 + <div class="box"> 15 + <h2 class="title is-5 mb-4"> 16 + <span class="icon-text"> 17 + <span class="icon has-text-link"> 18 + <i class="fas fa-lock"></i> 19 + </span> 20 + <span>Private Event Content</span> 21 + </span> 22 + </h2> 23 + 24 + <article class="message is-info"> 25 + <div class="message-body"> 26 + <p class="mb-2"> 27 + <strong>What is Private Content?</strong> 28 + </p> 29 + <p> 30 + Add content that will only be visible to attendees who meet specific criteria. This content is stored off-protocol and not published to your PDS, making it perfect for sharing sensitive information like meeting links, or exclusive details with confirmed attendees. 31 + </p> 32 + </div> 33 + </article> 34 + 35 + <form 36 + hx-post="/{{ handle_slug }}/{{ event_rkey }}/manage/content" 37 + hx-target="#tabs-and-content" 38 + hx-swap="outerHTML" 39 + > 40 + <div class="field"> 41 + <label class="label" for="privateContentTextarea"> 42 + Private Content 43 + </label> 44 + <div class="control"> 45 + <textarea 46 + class="textarea" 47 + id="privateContentTextarea" 48 + name="private_content" 49 + maxlength="5000" 50 + rows="8" 51 + placeholder="Enter information that only specific attendees can see...&#10;&#10;Examples:&#10;• Zoom meeting link and password&#10;• Venue access codes&#10;• Special instructions for attendees&#10;• Exclusive content or resources" 52 + >{% if build_event_form.private_content %}{{ build_event_form.private_content }}{% endif %}</textarea> 53 + </div> 54 + <p class="help"> 55 + Maximum 5,000 characters. Supports plain text formatting. 56 + </p> 57 + </div> 58 + 59 + <div class="field"> 60 + <label class="label">Display Criteria</label> 61 + <p class="help mb-3"> 62 + Select who can see this private content. Content will be shown if the viewer matches 63 + <strong>any</strong> selected criterion. 64 + </p> 65 + 66 + <div class="control"> 67 + <label class="checkbox is-block mb-3 p-3 box"> 68 + <input 69 + type="checkbox" 70 + name="private_content_criteria_going_confirmed" 71 + value="true" 72 + {% if build_event_form.private_content_criteria_going_confirmed %}checked{% endif %} 73 + > 74 + <strong class="ml-2">Confirmed Attendees</strong> 75 + <p class="help mt-1 ml-4"> 76 + Attendees with "Going" status who have accepted their RSVP ticket. 77 + <span class="tag is-success is-light is-small">Recommended</span> 78 + </p> 79 + </label> 80 + 81 + <label class="checkbox is-block mb-3 p-3 box"> 82 + <input 83 + type="checkbox" 84 + name="private_content_criteria_going" 85 + value="true" 86 + {% if build_event_form.private_content_criteria_going %}checked{% endif %} 87 + > 88 + <strong class="ml-2">All "Going" Attendees</strong> 89 + <p class="help mt-1 ml-4"> 90 + Anyone with "Going" status, regardless of acceptance. 91 + </p> 92 + </label> 93 + 94 + <label class="checkbox is-block p-3 box"> 95 + <input 96 + type="checkbox" 97 + name="private_content_criteria_interested" 98 + value="true" 99 + {% if build_event_form.private_content_criteria_interested %}checked{% endif %} 100 + > 101 + <strong class="ml-2">"Interested" Attendees</strong> 102 + <p class="help mt-1 ml-4"> 103 + Attendees who marked themselves as "Interested". 104 + </p> 105 + </label> 106 + </div> 107 + </div> 108 + 109 + <div class="field is-grouped is-grouped-right mt-5"> 110 + <div class="control"> 111 + <button type="submit" class="button is-link is-medium"> 112 + <span class="icon"> 113 + <i class="fas fa-save"></i> 114 + </span> 115 + <span>Save Private Content</span> 116 + </button> 117 + </div> 118 + </div> 119 + </form> 120 + </div> 121 + 122 + {# Preview of who can see the content #} 123 + {% if build_event_form.private_content %} 124 + <div class="box"> 125 + <h3 class="title is-6 mb-3"> 126 + <span class="icon-text"> 127 + <span class="icon has-text-grey"> 128 + <i class="fas fa-eye"></i> 129 + </span> 130 + <span>Content Visibility Preview</span> 131 + </span> 132 + </h3> 133 + 134 + <div class="notification is-light"> 135 + <p class="mb-2"><strong>This content will be shown to:</strong></p> 136 + <ul> 137 + {% if build_event_form.private_content_criteria_going_confirmed %} 138 + <li>Attendees with "Going" status who have accepted their RSVP</li> 139 + {% endif %} 140 + {% if build_event_form.private_content_criteria_going %} 141 + <li>All attendees with "Going" status</li> 142 + {% endif %} 143 + {% if build_event_form.private_content_criteria_interested %} 144 + <li>Attendees with "Interested" status</li> 145 + {% endif %} 146 + {% if not (build_event_form.private_content_criteria_going_confirmed or build_event_form.private_content_criteria_going or build_event_form.private_content_criteria_interested) %} 147 + <li class="has-text-danger"> 148 + <strong>No criteria selected!</strong> Content won't be shown to anyone. 149 + </li> 150 + {% endif %} 151 + </ul> 152 + </div> 153 + </div> 154 + {% endif %} 155 + </div>
+18
templates/en-us/manage_event_details_tab.html
··· 1 + {# Details Tab - Event Information Editing #} 2 + <div class="tab-pane-details"> 3 + {% if operation_completed %} 4 + <article class="message is-success mb-5"> 5 + <div class="message-header"> 6 + <p>Event Updated Successfully!</p> 7 + </div> 8 + <div class="message-body"> 9 + <p>Your changes have been saved.</p> 10 + </div> 11 + </article> 12 + {% endif %} 13 + 14 + <div class="box"> 15 + <h2 class="title is-5 mb-4">Event Information</h2> 16 + {% include 'en-us/create_event.partial.html' %} 17 + </div> 18 + </div>
+67
templates/en-us/manage_event_export_tab.html
··· 1 + {# Export Tab - Download RSVP data #} 2 + <div class="tab-pane-export"> 3 + <div class="box"> 4 + <h2 class="title is-5 mb-4"> 5 + <span class="icon-text"> 6 + <span class="icon has-text-primary"> 7 + <i class="fas fa-download"></i> 8 + </span> 9 + <span>Export RSVPs</span> 10 + </span> 11 + </h2> 12 + 13 + <p class="mb-4"> 14 + Download a CSV file of identities that have RSVP'd to your event. Perfect for 15 + importing into spreadsheets, CRM systems, or other tools. 16 + </p> 17 + 18 + {# Export statistics #} 19 + {% if rsvp_counts %} 20 + <div class="notification is-light mb-5"> 21 + <p class="heading">Total RSVPs to Export</p> 22 + <p class="title is-3"> 23 + {{ (rsvp_counts.going or 0) + (rsvp_counts.interested or 0) + (rsvp_counts.notgoing or 0) }} 24 + </p> 25 + <div class="tags mt-3"> 26 + <span class="tag is-success is-light is-medium"> 27 + {{ rsvp_counts.going or 0 }} Going 28 + </span> 29 + <span class="tag is-info is-light is-medium"> 30 + {{ rsvp_counts.interested or 0 }} Interested 31 + </span> 32 + <span class="tag is-light is-medium"> 33 + {{ rsvp_counts.notgoing or 0 }} Not Going 34 + </span> 35 + </div> 36 + </div> 37 + {% endif %} 38 + 39 + {# Export button #} 40 + <div class="buttons"> 41 + <a 42 + href="/{{ handle_slug }}/{{ event_rkey }}/export-rsvps" 43 + class="button is-primary is-medium" 44 + download 45 + > 46 + <span class="icon"> 47 + <i class="fas fa-file-csv"></i> 48 + </span> 49 + <span>Download CSV</span> 50 + </a> 51 + </div> 52 + 53 + {# What's included info #} 54 + <div class="content mt-5"> 55 + <h3 class="title is-6">What's Included in the Export</h3> 56 + <p>The CSV file contains the following information for each RSVP:</p> 57 + <ul> 58 + <li><strong>DID</strong> - Decentralized identifier</li> 59 + <li><strong>Handle</strong> - User's handle (if available)</li> 60 + <li><strong>Display Name</strong> - User's display name (if available)</li> 61 + <li><strong>RSVP Status</strong> - going, interested, or notgoing</li> 62 + <li><strong>RSVP Date</strong> - When the RSVP was created</li> 63 + <li><strong>Validation Status</strong> - Whether the RSVP has been validated</li> 64 + </ul> 65 + </div> 66 + </div> 67 + </div>
+113
templates/en-us/manage_event_settings_tab.html
··· 1 + {# Settings Tab - Advanced options and danger zone #} 2 + <div class="tab-pane-settings"> 3 + {# Advanced Settings #} 4 + <div class="box"> 5 + <h2 class="title is-6 mb-3"> 6 + <span class="icon-text"> 7 + <span class="icon has-text-grey"> 8 + <i class="fas fa-sliders-h"></i> 9 + </span> 10 + <span>Advanced Settings</span> 11 + </span> 12 + </h2> 13 + 14 + <div class="content"> 15 + <h3 class="subtitle is-6">Email Confirmation Requirement</h3> 16 + <p class="mb-3"> 17 + {% if event.require_confirmed_email %} 18 + <span class="tag is-success is-light"> 19 + <span class="icon"> 20 + <i class="fas fa-check"></i> 21 + </span> 22 + <span>Enabled</span> 23 + </span> 24 + <span class="ml-2">Only users with confirmed email addresses can RSVP to this event.</span> 25 + {% else %} 26 + <span class="tag is-light"> 27 + <span class="icon"> 28 + <i class="fas fa-times"></i> 29 + </span> 30 + <span>Disabled</span> 31 + </span> 32 + <span class="ml-2">Anyone can RSVP to this event.</span> 33 + {% endif %} 34 + </p> 35 + <p class="help"> 36 + This setting can be changed in the <a href="/{{ handle_slug }}/{{ event_rkey }}/manage?tab=details">Details tab</a>. 37 + </p> 38 + </div> 39 + 40 + <hr> 41 + 42 + <div class="content"> 43 + <h3 class="subtitle is-6">Event Visibility</h3> 44 + <p class="mb-2">Your event is publicly visible and can be:</p> 45 + <ul class="is-size-7"> 46 + <li>Discovered through search on Smoke Signal</li> 47 + <li>Viewed by anyone with the link</li> 48 + <li>Indexed by the AT Protocol firehose</li> 49 + <li>Shared on social media</li> 50 + </ul> 51 + <p class="help mt-3"> 52 + Events are always public in the AT Protocol. Use 53 + <a href="/{{ handle_slug }}/{{ event_rkey }}/manage?tab=content">Private Content</a> 54 + to share sensitive information with specific attendees. 55 + </p> 56 + </div> 57 + </div> 58 + 59 + {# Danger Zone #} 60 + <article class="message is-danger"> 61 + <div class="message-header"> 62 + <p> 63 + <span class="icon-text"> 64 + <span class="icon"> 65 + <i class="fas fa-exclamation-triangle"></i> 66 + </span> 67 + <span>Danger Zone</span> 68 + </span> 69 + </p> 70 + </div> 71 + <div class="message-body"> 72 + <h3 class="title is-6 mb-3">Delete This Event</h3> 73 + <p class="mb-3"> 74 + <strong>Deleting this event is permanent and cannot be undone.</strong> 75 + </p> 76 + <p class="mb-2">This will:</p> 77 + <ol class="mb-3"> 78 + <li>Remove the event from your Personal Data Server (PDS)</li> 79 + <li>Delete the event from this Smoke Signal instance</li> 80 + <li>Cause existing RSVP records to show "Unknown Event"</li> 81 + <li>Remove any private content associated with this event</li> 82 + </ol> 83 + <p class="mb-4"> 84 + <strong>Consider alternatives:</strong> If you need to cancel or reschedule, 85 + update the event status in the 86 + <a href="/{{ handle_slug }}/{{ event_rkey }}/manage?tab=details">Details tab</a> 87 + instead. 88 + </p> 89 + 90 + <form action="{{ delete_event_url }}" method="post"> 91 + <div class="field"> 92 + <div class="control"> 93 + <label class="checkbox"> 94 + <input type="checkbox" name="confirm" value="true" required> 95 + <strong>I understand that deleting this event cannot be undone</strong> 96 + </label> 97 + </div> 98 + </div> 99 + 100 + <div class="field mt-4"> 101 + <div class="control"> 102 + <button type="submit" class="button is-danger is-medium"> 103 + <span class="icon"> 104 + <i class="fas fa-trash"></i> 105 + </span> 106 + <span>Delete Event Permanently</span> 107 + </button> 108 + </div> 109 + </div> 110 + </form> 111 + </div> 112 + </article> 113 + </div>
+108
templates/en-us/manage_event_tabs.partial.html
··· 1 + {# Tab Navigation Component for Event Management #} 2 + {# Parameters: active_tab, handle_slug, event_rkey #} 3 + 4 + <div class="tabs is-boxed is-fullwidth mb-4" x-data="{ mobileMenuOpen: false }"> 5 + {# Mobile dropdown toggle #} 6 + <div class="is-hidden-tablet mb-3"> 7 + <button 8 + class="button is-fullwidth" 9 + @click="mobileMenuOpen = !mobileMenuOpen" 10 + aria-label="Toggle navigation menu" 11 + > 12 + <span class="icon"> 13 + <i class="fas fa-bars"></i> 14 + </span> 15 + <span> 16 + {% if active_tab == "details" %}Event Details 17 + {% elif active_tab == "attendees" %}Attendees 18 + {% elif active_tab == "content" %}Private Content 19 + {% elif active_tab == "export" %}Export 20 + {% elif active_tab == "settings" %}Settings 21 + {% else %}Menu 22 + {% endif %} 23 + </span> 24 + <span class="icon"> 25 + <i class="fas fa-chevron-down"></i> 26 + </span> 27 + </button> 28 + </div> 29 + 30 + {# Tab list - hidden on mobile unless menu is open #} 31 + <ul :class="{ 'is-hidden-mobile': !mobileMenuOpen }"> 32 + <li class="{% if active_tab == 'details' %}is-active{% endif %}"> 33 + <a 34 + hx-get="/{{ handle_slug }}/{{ event_rkey }}/manage?tab=details" 35 + hx-target="#tabs-and-content" 36 + hx-push-url="true" 37 + hx-indicator=".tab-loading" 38 + @click="mobileMenuOpen = false" 39 + > 40 + <span class="icon is-small"> 41 + <i class="fas fa-edit"></i> 42 + </span> 43 + <span>Details</span> 44 + </a> 45 + </li> 46 + <li class="{% if active_tab == 'attendees' %}is-active{% endif %}"> 47 + <a 48 + hx-get="/{{ handle_slug }}/{{ event_rkey }}/manage?tab=attendees" 49 + hx-target="#tabs-and-content" 50 + hx-push-url="true" 51 + hx-indicator=".tab-loading" 52 + @click="mobileMenuOpen = false" 53 + > 54 + <span class="icon is-small"> 55 + <i class="fas fa-users-gear"></i> 56 + </span> 57 + <span>Attendees</span> 58 + </a> 59 + </li> 60 + <li class="{% if active_tab == 'content' %}is-active{% endif %}"> 61 + <a 62 + hx-get="/{{ handle_slug }}/{{ event_rkey }}/manage?tab=content" 63 + hx-target="#tabs-and-content" 64 + hx-push-url="true" 65 + hx-indicator=".tab-loading" 66 + @click="mobileMenuOpen = false" 67 + > 68 + <span class="icon is-small"> 69 + <i class="fas fa-lock"></i> 70 + </span> 71 + <span>Private Content</span> 72 + </a> 73 + </li> 74 + <li class="{% if active_tab == 'export' %}is-active{% endif %}"> 75 + <a 76 + hx-get="/{{ handle_slug }}/{{ event_rkey }}/manage?tab=export" 77 + hx-target="#tabs-and-content" 78 + hx-push-url="true" 79 + hx-indicator=".tab-loading" 80 + @click="mobileMenuOpen = false" 81 + > 82 + <span class="icon is-small"> 83 + <i class="fas fa-download"></i> 84 + </span> 85 + <span>Export</span> 86 + </a> 87 + </li> 88 + <li class="{% if active_tab == 'settings' %}is-active{% endif %}"> 89 + <a 90 + hx-get="/{{ handle_slug }}/{{ event_rkey }}/manage?tab=settings" 91 + hx-target="#tabs-and-content" 92 + hx-push-url="true" 93 + hx-indicator=".tab-loading" 94 + @click="mobileMenuOpen = false" 95 + > 96 + <span class="icon is-small"> 97 + <i class="fas fa-cog"></i> 98 + </span> 99 + <span>Settings</span> 100 + </a> 101 + </li> 102 + </ul> 103 + </div> 104 + 105 + {# Loading indicator #} 106 + <div class="tab-loading htmx-indicator"> 107 + <progress class="progress is-small is-primary" max="100">Loading...</progress> 108 + </div>
+10
templates/en-us/manage_event_tabs_and_content.partial.html
··· 1 + {# Tabs and Content Container - for HTMX swaps #} 2 + {# This ensures both tabs and content are updated together so active states work correctly #} 3 + 4 + {# Tab Navigation #} 5 + {% include 'en-us/manage_event_tabs.partial.html' %} 6 + 7 + {# Tab Content Container #} 8 + <div id="tab-content"> 9 + {% include tab_template %} 10 + </div>
+121 -30
templates/en-us/view_event.common.html
··· 11 11 @{{ event.organizer_display_name }} 12 12 </a> 13 13 {% if can_edit %} 14 - <a href="{{ base }}/{{ handle_slug }}/{{ event_rkey }}/edit" 14 + <a href="{{ base }}/{{ handle_slug }}/{{ event_rkey }}/manage" 15 15 class="button is-small is-outlined is-primary ml-2"> 16 16 <span class="icon"> 17 - <i class="fas fa-edit"></i> 17 + <i class="fas fa-cog"></i> 18 18 </span> 19 - <span>Manage</span> 19 + <span>Manage Event</span> 20 20 </a> 21 21 {% endif %} 22 22 </h1> ··· 178 178 179 179 {% set show_download_ics = (user_rsvp_status == "going" or user_rsvp_status == "interested") and event.starts_at_human %} 180 180 181 + {% if validated_acceptance_metadata %} 182 + <!-- Compact: Validated/Finalized Acceptance --> 183 + <div class="notification is-success is-light" id="rsvpFrame"> 184 + <div class="level is-mobile"> 185 + <div class="level-left"> 186 + <div class="level-item"> 187 + <span class="icon-text"> 188 + <span class="icon"> 189 + <i class="fas fa-check-circle"></i> 190 + </span> 191 + <span class="has-text-weight-semibold">Your RSVP is signed by the event organizer.</span> 192 + </span> 193 + </div> 194 + </div> 195 + </div> 196 + </div> 197 + {% endif %} 198 + {% if pending_acceptance %} 199 + <!-- Compact: Acceptance Finalization CTA --> 200 + <div class="box has-background-primary-light" id="rsvpFrame" style="border-left: 4px solid #00d1b2;"> 201 + <div class="level is-mobile mb-0"> 202 + <div class="level-left"> 203 + <div class="level-item"> 204 + <span class="icon-text"> 205 + <span class="icon has-text-primary"> 206 + <i class="fas fa-ticket"></i> 207 + </span> 208 + <span class="has-text-weight-semibold">Organizer accepted your RSVP</span> 209 + </span> 210 + </div> 211 + </div> 212 + <div class="level-right"> 213 + <div class="level-item"> 214 + <form hx-post="/finalize_acceptance" hx-target="#acceptance-result" hx-swap="innerHTML" style="display: inline;"> 215 + <input type="hidden" name="acceptance_aturi" value="{{ pending_acceptance }}" /> 216 + <button type="submit" class="button is-primary"> 217 + <span class="icon"> 218 + <i class="fas fa-check-circle"></i> 219 + </span> 220 + <span>Confirm Attendance</span> 221 + </button> 222 + </form> 223 + </div> 224 + </div> 225 + </div> 226 + {% if pending_acceptance_metadata %} 227 + <div class="mt-3 pt-3" style="border-top: 1px solid rgba(0,0,0,0.1);"> 228 + <details> 229 + <summary class="has-text-weight-semibold is-size-7" style="cursor: pointer; user-select: none;"> 230 + <span class="icon-text"> 231 + <span class="icon is-small"> 232 + <i class="fas fa-info-circle"></i> 233 + </span> 234 + <span>View event details for you</span> 235 + </span> 236 + </summary> 237 + <div class="mt-2"> 238 + <table class="table is-narrow is-fullwidth"> 239 + <tbody> 240 + {% for (key, value) in pending_acceptance_metadata %} 241 + {% if key != "$type" %} 242 + <tr> 243 + <td class="has-text-weight-semibold is-size-7" style="width: 35%;">{{ key | replace("_", " ") | title }}</td> 244 + <td class="is-size-7">{{ value }}</td> 245 + </tr> 246 + {% endif %} 247 + {% endfor %} 248 + </tbody> 249 + </table> 250 + </div> 251 + </details> 252 + </div> 253 + {% endif %} 254 + <div id="acceptance-result" class="mt-3"></div> 255 + </div> 256 + {% else %} 257 + <!-- Regular: RSVP Form --> 181 258 <article class="message {% if user_rsvp_status %}is-success{% endif %}" id="rsvpFrame"> 182 259 <div class="message-body"> 183 260 {% if event.require_confirmed_email and not user_has_confirmed_email %} ··· 203 280 {% elif user_rsvp_status == "interested" %} 204 281 <p>You have RSVP'd <strong>Interested</strong>.</p> 205 282 {% elif user_rsvp_status == "notgoing" %} 206 - <p>You have RSVP'd <strong>Not Goiing</strong>.</p> 207 - {% endif %} 208 - {% if pending_acceptance %} 209 - <article class="message is-info mt-3"> 210 - <div class="message-body"> 211 - <p class="mb-2"><strong>Your RSVP has been accepted!</strong></p> 212 - <p class="mb-3">The event organizer has accepted your RSVP. Click below to finalize your acceptance.</p> 213 - <form hx-post="/finalize_acceptance" hx-target="#acceptance-result" hx-swap="innerHTML"> 214 - <input type="hidden" name="acceptance_aturi" value="{{ pending_acceptance }}" /> 215 - <button type="submit" class="button is-primary"> 216 - <span class="icon"> 217 - <i class="fas fa-check-circle"></i> 218 - </span> 219 - <span>Finalize Acceptance</span> 220 - </button> 221 - </form> 222 - <div id="acceptance-result" class="mt-3"></div> 223 - </div> 224 - </article> 283 + <p>You have RSVP'd <strong>Not Going</strong>.</p> 225 284 {% endif %} 226 285 {% if show_download_ics %} 227 286 <p> ··· 266 325 </div> 267 326 </article> 268 327 {% endif %} 328 + {% endif %} 269 329 </div> 270 330 </section> 271 331 ··· 279 339 </div> 280 340 </section> 281 341 282 - {% if private_content %} 283 - <section class="section"> 342 + {% if private_content and private_content|trim %} 343 + <section class="section" id="privatecontent"> 284 344 <div class="container"> 285 345 <article class="message is-info"> 286 346 <div class="message-header"> ··· 292 352 </p> 293 353 </div> 294 354 <div class="message-body" style="word-break: break-word; white-space: pre-wrap;"> 295 - {{ private_content }} 355 + {{- private_content -}} 296 356 </div> 297 357 </article> 298 358 </div> 299 359 </section> 300 360 {% endif %} 301 361 362 + {% if validated_acceptance_metadata and validated_acceptance_metadata|length > 1 %} 363 + <section class="section" id="signaturedata"> 364 + <div class="container"> 365 + <div class="box"> 366 + <h3 class="title is-5"> 367 + <span class="icon-text"> 368 + <span class="icon has-text-info"> 369 + <i class="fas fa-info-circle"></i> 370 + </span> 371 + <span>Event Organizer Signature Data</span> 372 + </span> 373 + </h3> 374 + <div class="content"> 375 + <table class="table is-fullwidth is-striped"> 376 + <tbody> 377 + {% for (key, value) in validated_acceptance_metadata %} 378 + {% if key != "$type" %} 379 + <tr> 380 + <td class="has-text-weight-semibold" style="width: 30%;">{{ key }}</td> 381 + <td>{{ value }}</td> 382 + </tr> 383 + {% endif %} 384 + {% endfor %} 385 + </tbody> 386 + </table> 387 + </div> 388 + </div> 389 + </div> 390 + </section> 391 + {% endif %} 392 + 302 393 <section class="section" id="rsvps"> 303 394 <div class="container"> 304 395 <div class="tabs"> ··· 323 414 <div class="grid is-col-min-12 has-text-centered"> 324 415 {% for rsvp in active_tab_rsvps %} 325 416 <span class="cell"> 417 + {% if rsvp.validated_at %} 418 + <span class="icon" title="This RSVP is signed by the event organizer."> 419 + <i class="fas fa-check-circle"></i> 420 + </span> 421 + {% endif %} 326 422 {% if rsvp.handle %} 327 423 <a href="/@{{ rsvp.handle }}">@{{ rsvp.handle }}</a> 328 424 {% else %} 329 425 <span class="has-text-grey">{{ rsvp.did }}</span> 330 - {% endif %} 331 - {% if rsvp.validated_at %} 332 - <span class="icon has-text-primary" title="Verified RSVP - Organizer has accepted this response"> 333 - <i class="fas fa-certificate"></i> 334 - </span> 335 426 {% endif %} 336 427 </span> 337 428 {% endfor %}