···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+}
···27 utils::url_from_aturi,
28 },
29 select_template,
30+ storage::event::{RsvpInsertParams, rsvp_get_by_event_and_did, rsvp_insert_with_metadata},
31 storage::webhook::webhook_list_enabled_by_did,
32 task_webhooks::TaskWork,
33};
···180181 let record_key = crockford::encode(h.finish());
182183+ // 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+ ¤t_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+224 let the_record = Rsvp {
225+ created_at: created_at_timestamp,
226 subject,
227 status,
228+ signatures: signatures_to_use,
229 extra: Default::default(),
230 };
231···284 event_aturi: build_rsvp_form.subject_aturi.as_ref().unwrap(),
285 event_cid: build_rsvp_form.subject_cid.as_ref().unwrap(),
286 status: build_rsvp_form.status.as_ref().unwrap(),
287+ clear_validated_at: status_changed,
288 },
289 )
290 .await;
+31-324
src/http/handle_edit_event.rs
···1use anyhow::Result;
2use axum::{extract::Path, response::IntoResponse};
3use axum_extra::extract::Form;
4-use axum_htmx::{HxBoosted, HxRequest};
5-use axum_template::RenderHtml;
6use chrono::Utc;
7-use http::{Method, StatusCode};
8use minijinja::context as template_context;
910use crate::atproto::auth::{
···21 http::event_form::BuildLocationForm,
22 http::event_form::{BuildEventContentState, BuildEventForm, BuildStartsForm},
23 http::location_edit_status::{LocationEditStatus, check_location_edit_status},
24- http::timezones::supported_timezones,
25 http::utils::url_from_aturi,
26 select_template,
27 storage::{
28- event::{event_get, event_update_with_metadata, get_event_rsvps_for_export},
29 identity_profile::{handle_for_did, handle_for_handle},
30 },
31};
···3738pub(crate) async fn handle_edit_event(
39 ctx: UserRequestContext,
40- method: Method,
41- HxBoosted(hx_boosted): HxBoosted,
42- HxRequest(hx_request): HxRequest,
43 Path((handle_slug, event_rkey)): Path<(String, String)>,
44 Form(mut build_event_form): Form<BuildEventForm>,
45) -> Result<impl IntoResponse, WebError> {
···48 let default_context = template_context! {
49 current_handle,
50 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 };
5758- 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);
6061 // Lookup the event
62 let profile = if handle_slug.starts_with("did:") {
···105106 let event = event.unwrap();
107108- // 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 // Check if this is a community calendar event (we only support editing those)
125 if event.lexicon != LexiconCommunityEventNSID {
126 return contextual_error!(
···149 }
150 };
151152- 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 if build_event_form.build_state.is_none() {
159 build_event_form.build_state = Some(BuildEventContentState::default());
160 }
···162 let mut starts_form = BuildStartsForm::from(build_event_form.clone());
163 if starts_form.build_state.is_none() {
164 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 }
170171 let mut location_form = BuildLocationForm::from(build_event_form.clone());
···173 location_form.build_state = Some(BuildEventContentState::default());
174 }
175176- 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
412 match build_event_form.build_state {
413 Some(BuildEventContentState::Reset) => {
414 build_event_form.build_state = Some(BuildEventContentState::Selecting);
···679 );
680 }
68100000000000000000682 // Save private event content if provided
683 let private_content = build_event_form.private_content.as_deref().unwrap_or("");
684···787 }
788 }
789790- 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());
816 }
817 }
818 _ => {}
819 }
820821- 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())
842}
···1use anyhow::Result;
2use axum::{extract::Path, response::IntoResponse};
3use axum_extra::extract::Form;
4+use axum_htmx::HxRedirect;
05use chrono::Utc;
6+use http::StatusCode;
7use minijinja::context as template_context;
89use crate::atproto::auth::{
···20 http::event_form::BuildLocationForm,
21 http::event_form::{BuildEventContentState, BuildEventForm, BuildStartsForm},
22 http::location_edit_status::{LocationEditStatus, check_location_edit_status},
023 http::utils::url_from_aturi,
24 select_template,
25 storage::{
26+ event::{event_get, event_update_with_metadata},
27 identity_profile::{handle_for_did, handle_for_handle},
28 },
29};
···3536pub(crate) async fn handle_edit_event(
37 ctx: UserRequestContext,
00038 Path((handle_slug, event_rkey)): Path<(String, String)>,
39 Form(mut build_event_form): Form<BuildEventForm>,
40) -> Result<impl IntoResponse, WebError> {
···43 let default_context = template_context! {
44 current_handle,
45 language => ctx.language.to_string(),
0000046 };
4748+ let error_template = select_template!(false, false, ctx.language);
04950 // Lookup the event
51 let profile = if handle_slug.starts_with("did:") {
···9495 let event = event.unwrap();
96000000000000000097 // Check if this is a community calendar event (we only support editing those)
98 if event.lexicon != LexiconCommunityEventNSID {
99 return contextual_error!(
···122 }
123 };
124000000125 if build_event_form.build_state.is_none() {
126 build_event_form.build_state = Some(BuildEventContentState::default());
127 }
···129 let mut starts_form = BuildStartsForm::from(build_event_form.clone());
130 if starts_form.build_state.is_none() {
131 starts_form.build_state = Some(BuildEventContentState::default());
0000132 }
133134 let mut location_form = BuildLocationForm::from(build_event_form.clone());
···136 location_form.build_state = Some(BuildEventContentState::default());
137 }
138139+ // Process form state changes for POST request
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000140 match build_event_form.build_state {
141 Some(BuildEventContentState::Reset) => {
142 build_event_form.build_state = Some(BuildEventContentState::Selecting);
···407 );
408 }
409410+ // 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+427 // Save private event content if provided
428 let private_content = build_event_form.private_content.as_deref().unwrap_or("");
429···532 }
533 }
534535+ // 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());
0000000000000000000000539 }
540 }
541 _ => {}
542 }
543544+ // 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())
0000000000000000549}
···20 middleware_i18n::Language,
21 },
22 storage::{
23- acceptance::{acceptance_ticket_delete, acceptance_ticket_get},
00024 event::rsvp_get_by_event_and_did,
25 },
26};
···40) -> Result<impl IntoResponse, WebError> {
41 let current_handle = auth.require("/finalize_acceptance")?;
4243- // Get the acceptance ticket from storage
44- let ticket = acceptance_ticket_get(&web_context.pool, &form.acceptance_aturi)
045 .await
46 .map_err(|e| anyhow!("Failed to get acceptance ticket: {}", e))?
47 .ok_or_else(|| anyhow!("Acceptance ticket not found"))?;
4849 // Verify the current user is the RSVP creator (recipient of the acceptance)
50- if ticket.rsvp_did != current_handle.did {
51 return Err(CommonError::NotAuthorized.into());
52 }
000000000005354 // Get the RSVP to verify it exists and get its aturi
55 let rsvp = rsvp_get_by_event_and_did(&web_context.pool, &ticket.event_aturi, &ticket.rsvp_did)
···103 };
104105 // Deserialize the RSVP record from storage
106- let rsvp_record: Rsvp = serde_json::from_value(rsvp.record.0.clone())
107 .map_err(|e| anyhow!("Failed to deserialize RSVP record: {}", e))?;
108109- // 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);
0000000000118119- // Append the remote attestation (acceptance) to the RSVP
00000120 let updated_rsvp_record = append_remote_attestation(
121 typed_base_rsvp.into(),
122 acceptance_record.into(), // Convert to AnyInput
···172 }
173 };
174175- // Delete the acceptance ticket from storage (cleanup)
176- acceptance_ticket_delete(&web_context.pool, &ticket.aturi)
177 .await
178- .map_err(|e| anyhow!("Failed to delete acceptance ticket: {}", e))?;
0000000000179180 // Return success with HTMX-compatible HTML
181 Ok((
···20 middleware_i18n::Language,
21 },
22 storage::{
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+ },
27 event::rsvp_get_by_event_and_did,
28 },
29};
···43) -> Result<impl IntoResponse, WebError> {
44 let current_handle = auth.require("/finalize_acceptance")?;
4546+ // 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)
49 .await
50 .map_err(|e| anyhow!("Failed to get acceptance ticket: {}", e))?
51 .ok_or_else(|| anyhow!("Acceptance ticket not found"))?;
5253 // Verify the current user is the RSVP creator (recipient of the acceptance)
54+ if old_ticket.rsvp_did != current_handle.did {
55 return Err(CommonError::NotAuthorized.into());
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+ ¤t_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"))?;
6869 // Get the RSVP to verify it exists and get its aturi
70 let rsvp = rsvp_get_by_event_and_did(&web_context.pool, &ticket.event_aturi, &ticket.rsvp_did)
···118 };
119120 // Deserialize the RSVP record from storage
121+ let mut rsvp_record: Rsvp = serde_json::from_value(rsvp.record.0.clone())
122 .map_err(|e| anyhow!("Failed to deserialize RSVP record: {}", e))?;
123124+ // 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+ });
143144+ // 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
150 let updated_rsvp_record = append_remote_attestation(
151 typed_base_rsvp.into(),
152 acceptance_record.into(), // Convert to AnyInput
···202 }
203 };
204205+ // 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()))
207 .await
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+ ¤t_handle.did,
216+ )
217+ .await
218+ .map_err(|e| anyhow!("Failed to delete acceptance ticket: {}", e))?;
219220 // Return success with HTMX-compatible HTML
221 Ok((
···219 );
220221 if hx_request {
222- let hx_redirect = HxRedirect::try_from(destination.as_str())
223- .expect("HxRedirect construction should not fail");
224 return Ok((StatusCode::OK, hx_redirect, "").into_response());
225 }
226
···219 );
220221 if hx_request {
222+ let hx_redirect = HxRedirect::from(destination.as_str());
0223 return Ok((StatusCode::OK, hx_redirect, "").into_response());
224 }
225
···01pub mod acceptance_utils;
2pub mod auth_utils;
3pub mod cache_countries;
···16pub mod handle_admin_rsvp;
17pub mod handle_admin_rsvps;
18pub mod handle_blob;
019pub mod handle_content;
20pub mod handle_create_event;
21pub mod handle_create_rsvp;
···30pub mod handle_import;
31pub mod handle_index;
32pub mod handle_mailgun_webhook;
0033pub mod handle_oauth_aip_callback;
34pub mod handle_oauth_aip_login;
35pub mod handle_oauth_callback;
···39pub mod handle_profile;
40pub mod handle_set_language;
41pub mod handle_settings;
042pub mod handle_unsubscribe;
43pub mod handle_view_event;
44pub mod handle_wellknown;
···1+pub mod acceptance_operations;
2pub mod acceptance_utils;
3pub mod auth_utils;
4pub mod cache_countries;
···17pub mod handle_admin_rsvp;
18pub mod handle_admin_rsvps;
19pub mod handle_blob;
20+pub mod handle_bulk_accept_rsvps;
21pub mod handle_content;
22pub mod handle_create_event;
23pub mod handle_create_rsvp;
···32pub mod handle_import;
33pub mod handle_index;
34pub mod handle_mailgun_webhook;
35+pub mod handle_manage_event;
36+pub mod handle_manage_event_content;
37pub mod handle_oauth_aip_callback;
38pub mod handle_oauth_aip_login;
39pub mod handle_oauth_callback;
···43pub mod handle_profile;
44pub mod handle_set_language;
45pub mod handle_settings;
46+pub mod handle_unaccept_rsvp;
47pub mod handle_unsubscribe;
48pub mod handle_view_event;
49pub mod handle_wellknown;
···29}
3031/// Insert or update an acceptance ticket
0032pub async fn acceptance_ticket_upsert<T: serde::Serialize>(
33 pool: &StoragePool,
34 aturi: &str,
···4445 let now = Utc::now();
46000000000000047 sqlx::query(
48 "INSERT INTO acceptance_tickets (aturi, did, rsvp_did, event_aturi, record, created_at)
49 VALUES ($1, $2, $3, $4, $5, $6)
···286}
287288/// Get acceptance ticket for a specific event and RSVP DID combination
0289pub async fn acceptance_ticket_get_by_event_and_rsvp_did(
290 pool: &StoragePool,
291 event_aturi: &str,
···297 .map_err(StorageError::CannotBeginDatabaseTransaction)?;
298299 let record = sqlx::query_as::<_, AcceptanceTicket>(
300- "SELECT * FROM acceptance_tickets WHERE event_aturi = $1 AND rsvp_did = $2 LIMIT 1",
301 )
302 .bind(event_aturi)
303 .bind(rsvp_did)
···312 Ok(record)
313}
31400000000000000000000000315/// Update RSVP validated_at timestamp
316pub async fn rsvp_update_validated_at(
317 pool: &StoragePool,
···334 .await
335 .map_err(StorageError::CannotCommitDatabaseTransaction)
336}
0000000000000000000000000000
···29}
3031/// 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)
34pub async fn acceptance_ticket_upsert<T: serde::Serialize>(
35 pool: &StoragePool,
36 aturi: &str,
···4647 let now = Utc::now();
4849+ // 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
62 sqlx::query(
63 "INSERT INTO acceptance_tickets (aturi, did, rsvp_did, event_aturi, record, created_at)
64 VALUES ($1, $2, $3, $4, $5, $6)
···301}
302303/// 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)
305pub async fn acceptance_ticket_get_by_event_and_rsvp_did(
306 pool: &StoragePool,
307 event_aturi: &str,
···313 .map_err(StorageError::CannotBeginDatabaseTransaction)?;
314315 let record = sqlx::query_as::<_, AcceptanceTicket>(
316+ "SELECT * FROM acceptance_tickets WHERE event_aturi = $1 AND rsvp_did = $2 ORDER BY created_at DESC LIMIT 1",
317 )
318 .bind(event_aturi)
319 .bind(rsvp_did)
···328 Ok(record)
329}
330331+/// 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+354/// Update RSVP validated_at timestamp
355pub async fn rsvp_update_validated_at(
356 pool: &StoragePool,
···373 .await
374 .map_err(StorageError::CannotCommitDatabaseTransaction)
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 let name = &record.name;
9495 // Call the new function with extracted values
96- event_insert_with_metadata(pool, aturi, cid, did, lexicon, record, name, false).await
000000000000000000000097}
9899pub async fn event_insert_with_metadata<T: serde::Serialize>(
100 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,
108) -> Result<(), StorageError> {
109 let mut tx = pool
110 .begin()
···114 let now = Utc::now();
115116 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)
124 .bind(now)
125 .execute(tx.as_mut())
126 .await
···140 pub event_aturi: &'a str,
141 pub event_cid: &'a str,
142 pub status: &'a str,
0143}
144145pub async fn rsvp_insert_with_metadata<T: serde::Serialize>(
···154 let now = Utc::now();
155156 // 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")
000158 .bind(params.aturi)
159 .bind(params.cid)
160 .bind(params.did)
···167 .execute(tx.as_mut())
168 .await
169 .map_err(StorageError::UnableToExecuteQuery)?;
000000000000000170171 tx.commit()
172 .await
···202 event_aturi: &event_aturi,
203 event_cid: &event_cid,
204 status,
0205 },
206 )
207 .await
···1180 pub handle: Option<String>,
1181 pub status: String,
1182 pub created_at: Option<chrono::DateTime<chrono::Utc>>,
0001183}
11841185#[derive(Debug, Serialize, Clone)]
···1213 r.did,
1214 ip.handle,
1215 r.status,
1216- r.updated_at
01217 FROM rsvps r
1218 LEFT JOIN identity_profiles ip ON r.did = ip.did
1219 WHERE r.event_aturi = $1
···1228 String,
1229 Option<String>,
1230 String,
01231 Option<chrono::DateTime<chrono::Utc>>,
1232 ),
1233 >(query)
···1243 let export_data: Vec<RsvpExportData> = rsvps
1244 .into_iter()
1245 .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,
00001253 },
1254 )
1255 .collect();
···93 let name = &record.name;
9495 // Call the new function with extracted values
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,
119}
120121pub async fn event_insert_with_metadata<T: serde::Serialize>(
122 pool: &StoragePool,
123+ params: EventInsertParams<'_, T>,
000000124) -> Result<(), StorageError> {
125 let mut tx = pool
126 .begin()
···130 let now = Utc::now();
131132 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")
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)
140 .bind(now)
141 .execute(tx.as_mut())
142 .await
···156 pub event_aturi: &'a str,
157 pub event_cid: &'a str,
158 pub status: &'a str,
159+ pub clear_validated_at: bool,
160}
161162pub async fn rsvp_insert_with_metadata<T: serde::Serialize>(
···171 let now = Utc::now();
172173 // TODO: This should probably also update event_aturi and event_cid
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")
178 .bind(params.aturi)
179 .bind(params.cid)
180 .bind(params.did)
···187 .execute(tx.as_mut())
188 .await
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+ }
205206 tx.commit()
207 .await
···237 event_aturi: &event_aturi,
238 event_cid: &event_cid,
239 status,
240+ clear_validated_at: false,
241 },
242 )
243 .await
···1216 pub handle: Option<String>,
1217 pub status: String,
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>>,
1222}
12231224#[derive(Debug, Serialize, Clone)]
···1252 r.did,
1253 ip.handle,
1254 r.status,
1255+ r.updated_at,
1256+ r.validated_at
1257 FROM rsvps r
1258 LEFT JOIN identity_profiles ip ON r.did = ip.did
1259 WHERE r.event_aturi = $1
···1268 String,
1269 Option<String>,
1270 String,
1271+ Option<chrono::DateTime<chrono::Utc>>,
1272 Option<chrono::DateTime<chrono::Utc>>,
1273 ),
1274 >(query)
···1284 let export_data: Vec<RsvpExportData> = rsvps
1285 .into_iter()
1286 .map(
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+ }
1298 },
1299 )
1300 .collect();
···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>