···11+use anyhow::Result;
22+use atproto_attestation::cid::create_attestation_cid;
33+use atproto_client::com::atproto::repo::{PutRecordRequest, PutRecordResponse, put_record};
44+use atproto_record::{
55+ lexicon::community::lexicon::calendar::rsvp::{Rsvp, TypedRsvp},
66+ typed::TypedLexicon,
77+};
88+use metrohash::MetroHash64;
99+use std::collections::HashMap;
1010+use std::hash::Hasher;
1111+1212+use crate::{
1313+ atproto::lexicon::acceptance::{Acceptance, NSID as ACCEPTANCE_NSID, TypedAcceptance},
1414+ http::context::WebContext,
1515+ storage::acceptance::{
1616+ acceptance_record_delete, acceptance_ticket_get_by_event_and_rsvp_did,
1717+ acceptance_ticket_upsert, rsvp_update_validated_at,
1818+ },
1919+};
2020+2121+/// Generate a deterministic record key from an RSVP AT-URI
2222+///
2323+/// Uses MetroHash64 to create a fast, deterministic hash of the RSVP AT-URI,
2424+/// then encodes it as base32 (lowercase) to create a valid AT Protocol record key.
2525+///
2626+/// This ensures:
2727+/// - Same RSVP always maps to same acceptance record key (idempotency)
2828+/// - Different RSVPs get different keys (collision-resistant)
2929+/// - Keys are valid for AT Protocol (alphanumeric + lowercase)
3030+fn generate_acceptance_record_key(rsvp_aturi: &str) -> String {
3131+ let mut hasher = MetroHash64::new();
3232+ hasher.write(rsvp_aturi.as_bytes());
3333+ let hash = hasher.finish();
3434+3535+ // Convert u64 hash to base32 lowercase for a valid record key
3636+ // Using base32 gives us a shorter, URL-safe string
3737+ base32::encode(
3838+ base32::Alphabet::Rfc4648 { padding: false },
3939+ &hash.to_le_bytes(),
4040+ )
4141+ .to_lowercase()
4242+}
4343+4444+/// Result of checking if an acceptance can be updated
4545+#[derive(Debug)]
4646+pub enum AcceptanceStatus {
4747+ /// No existing acceptance - should create new
4848+ NotExists,
4949+ /// Draft acceptance exists - can be updated
5050+ CanUpdate { existing_aturi: String },
5151+ /// Acceptance has been finalized by attendee - should NOT update
5252+ Finalized,
5353+}
5454+5555+/// Check if an acceptance exists and whether it can be updated
5656+pub async fn check_acceptance_status(
5757+ web_context: &WebContext,
5858+ event_aturi: &str,
5959+ rsvp_did: &str,
6060+) -> Result<AcceptanceStatus> {
6161+ // Check if there's an existing acceptance ticket (draft)
6262+ match acceptance_ticket_get_by_event_and_rsvp_did(&web_context.pool, event_aturi, rsvp_did)
6363+ .await?
6464+ {
6565+ Some(ticket) => {
6666+ // Ticket exists - check if RSVP has been validated (finalized)
6767+ // Get the RSVP by event and attendee DID to check validated_at status
6868+ if crate::storage::event::rsvp_get_by_event_and_did(
6969+ &web_context.pool,
7070+ event_aturi,
7171+ rsvp_did,
7272+ )
7373+ .await?
7474+ .filter(|r| r.validated_at.is_some())
7575+ .is_some()
7676+ {
7777+ // RSVP has been validated - acceptance is finalized
7878+ return Ok(AcceptanceStatus::Finalized);
7979+ }
8080+8181+ // Draft exists and not finalized - can update
8282+ Ok(AcceptanceStatus::CanUpdate {
8383+ existing_aturi: ticket.aturi,
8484+ })
8585+ }
8686+ None => {
8787+ // No existing acceptance ticket
8888+ Ok(AcceptanceStatus::NotExists)
8989+ }
9090+ }
9191+}
9292+9393+/// Create or update an acceptance record using deterministic record keys
9494+///
9595+/// This function uses a deterministic record key based on the RSVP AT-URI,
9696+/// which provides natural idempotency - calling this multiple times for the
9797+/// same RSVP will update the existing acceptance rather than creating duplicates.
9898+///
9999+/// Note: Organizers can update acceptances even after they've been finalized by attendees.
100100+/// This allows organizers to update metadata like table assignments or other event details.
101101+pub async fn create_or_update_acceptance(
102102+ web_context: &WebContext,
103103+ dpop_auth: atproto_client::client::DPoPAuth,
104104+ current_did: &str,
105105+ current_pds: &str,
106106+ rsvp: &crate::storage::event::model::Rsvp,
107107+ rsvp_record: &Rsvp,
108108+ metadata: &HashMap<String, serde_json::Value>,
109109+) -> Result<(String, bool)> {
110110+ // Generate deterministic record key from RSVP AT-URI
111111+ let record_key = generate_acceptance_record_key(&rsvp.aturi);
112112+113113+ // Try to get existing record to determine if this is create or update
114114+ let existing_record = atproto_client::com::atproto::repo::get_record(
115115+ &web_context.http_client,
116116+ &atproto_client::client::Auth::None,
117117+ current_pds,
118118+ current_did,
119119+ ACCEPTANCE_NSID,
120120+ &record_key,
121121+ None,
122122+ )
123123+ .await;
124124+125125+ let (swap_cid, is_update) = match existing_record {
126126+ Ok(atproto_client::com::atproto::repo::GetRecordResponse::Record { cid, .. }) => {
127127+ (Some(cid), true)
128128+ }
129129+ _ => (None, false),
130130+ };
131131+132132+ // Create the acceptance record with metadata FIRST
133133+ // We need to build this before computing the CID because the CID must include ALL fields
134134+ let acceptance = Acceptance {
135135+ cid: String::new(), // Placeholder - will be replaced
136136+ extra: metadata.clone(),
137137+ };
138138+139139+ let typed_acceptance = TypedAcceptance::new(acceptance.clone());
140140+141141+ // Serialize to get the full metadata structure that will be in the acceptance record
142142+ // This includes $type, and all metadata fields (but not 'cid' yet)
143143+ let mut acceptance_metadata = serde_json::to_value(&typed_acceptance)
144144+ .map_err(|e| anyhow::anyhow!("Failed to serialize acceptance: {}", e))?;
145145+146146+ // Remove the placeholder cid field (empty string) from the metadata
147147+ if let serde_json::Value::Object(ref mut map) = acceptance_metadata {
148148+ map.remove("cid");
149149+ }
150150+151151+ // NOW create attestation CID with the full metadata structure
152152+ let typed_rsvp: TypedRsvp = TypedLexicon::new(rsvp_record.clone());
153153+ let content_cid =
154154+ create_attestation_cid(typed_rsvp.into(), acceptance_metadata.into(), current_did)
155155+ .map_err(|e| anyhow::anyhow!("Failed to create remote attestation proof: {}", e))?;
156156+157157+ // Update the acceptance with the correct CID
158158+ let acceptance = Acceptance {
159159+ cid: content_cid.to_string(),
160160+ extra: metadata.clone(),
161161+ };
162162+163163+ let typed_acceptance = TypedAcceptance::new(acceptance.clone());
164164+165165+ // Put record (create or update) with deterministic key
166166+ let put_request = PutRecordRequest {
167167+ repo: current_did.to_string(),
168168+ collection: ACCEPTANCE_NSID.to_string(),
169169+ validate: false,
170170+ record_key: record_key.clone(),
171171+ record: typed_acceptance.clone(),
172172+ swap_commit: None,
173173+ swap_record: swap_cid, // Use swap_record to prevent race conditions
174174+ };
175175+176176+ let put_result = put_record(
177177+ &web_context.http_client,
178178+ &atproto_client::client::Auth::DPoP(dpop_auth),
179179+ current_pds,
180180+ put_request,
181181+ )
182182+ .await?;
183183+184184+ match put_result {
185185+ PutRecordResponse::StrongRef { uri, .. } => Ok((uri, is_update)),
186186+ PutRecordResponse::Error(err) => Err(anyhow::anyhow!(
187187+ "AT Protocol server rejected acceptance: {}",
188188+ err.error_message()
189189+ )),
190190+ }
191191+}
192192+193193+/// Store or update the acceptance ticket in local database
194194+pub async fn store_acceptance_ticket(
195195+ web_context: &WebContext,
196196+ acceptance_uri: &str,
197197+ organizer_did: &str,
198198+ rsvp_did: &str,
199199+ event_aturi: &str,
200200+ acceptance: &Acceptance,
201201+) -> Result<()> {
202202+ acceptance_ticket_upsert(
203203+ &web_context.pool,
204204+ acceptance_uri,
205205+ organizer_did,
206206+ rsvp_did,
207207+ event_aturi,
208208+ acceptance,
209209+ )
210210+ .await
211211+ .map_err(|e| anyhow::anyhow!("Failed to store acceptance ticket: {}", e))
212212+}
213213+214214+/// Delete an acceptance record (unaccept an RSVP)
215215+///
216216+/// This removes the acceptance record from AT Protocol and deletes the acceptance ticket
217217+/// from the local database. This allows organizers to revoke an acceptance.
218218+pub async fn delete_acceptance(
219219+ web_context: &WebContext,
220220+ dpop_auth: atproto_client::client::DPoPAuth,
221221+ current_did: &str,
222222+ current_pds: &str,
223223+ rsvp_aturi: &str,
224224+ event_aturi: &str,
225225+ rsvp_did: &str,
226226+) -> Result<()> {
227227+ // Generate deterministic record key from RSVP AT-URI
228228+ let record_key = generate_acceptance_record_key(rsvp_aturi);
229229+230230+ // Delete the record from AT Protocol
231231+ let delete_request = atproto_client::com::atproto::repo::DeleteRecordRequest {
232232+ repo: current_did.to_string(),
233233+ collection: ACCEPTANCE_NSID.to_string(),
234234+ record_key: record_key.clone(),
235235+ swap_commit: None,
236236+ swap_record: None,
237237+ };
238238+239239+ let delete_result = atproto_client::com::atproto::repo::delete_record(
240240+ &web_context.http_client,
241241+ &atproto_client::client::Auth::DPoP(dpop_auth),
242242+ current_pds,
243243+ delete_request,
244244+ )
245245+ .await?;
246246+247247+ match delete_result {
248248+ atproto_client::com::atproto::repo::DeleteRecordResponse::Commit { .. } => {
249249+ // Construct the acceptance AT-URI
250250+ let acceptance_aturi =
251251+ format!("at://{}/{}/{}", current_did, ACCEPTANCE_NSID, record_key);
252252+253253+ // Delete acceptance record from local database
254254+ acceptance_record_delete(&web_context.pool, &acceptance_aturi)
255255+ .await
256256+ .map_err(|e| anyhow::anyhow!("Failed to delete acceptance record: {}", e))?;
257257+258258+ // Delete acceptance ticket from local database
259259+ crate::storage::acceptance::acceptance_ticket_delete_by_event_and_rsvp_did(
260260+ &web_context.pool,
261261+ event_aturi,
262262+ rsvp_did,
263263+ )
264264+ .await
265265+ .map_err(|e| anyhow::anyhow!("Failed to delete acceptance ticket: {}", e))?;
266266+267267+ // Clear the validated_at timestamp on the RSVP record
268268+ rsvp_update_validated_at(&web_context.pool, rsvp_aturi, None)
269269+ .await
270270+ .map_err(|e| anyhow::anyhow!("Failed to clear RSVP validated_at: {}", e))?;
271271+272272+ Ok(())
273273+ }
274274+ atproto_client::com::atproto::repo::DeleteRecordResponse::Error(err) => {
275275+ Err(anyhow::anyhow!(
276276+ "AT Protocol server rejected deletion: {}",
277277+ err.error_message()
278278+ ))
279279+ }
280280+ }
281281+}
282282+283283+#[cfg(test)]
284284+mod tests {
285285+ use super::*;
286286+287287+ #[test]
288288+ fn test_generate_acceptance_record_key_deterministic() {
289289+ let rsvp_aturi = "at://did:plc:abc123/community.lexicon.calendar.rsvp/xyz789";
290290+291291+ // Same input should always produce same output
292292+ let key1 = generate_acceptance_record_key(rsvp_aturi);
293293+ let key2 = generate_acceptance_record_key(rsvp_aturi);
294294+295295+ assert_eq!(key1, key2);
296296+ assert!(!key1.is_empty());
297297+ }
298298+299299+ #[test]
300300+ fn test_generate_acceptance_record_key_unique() {
301301+ let rsvp1 = "at://did:plc:abc123/community.lexicon.calendar.rsvp/xyz789";
302302+ let rsvp2 = "at://did:plc:def456/community.lexicon.calendar.rsvp/uvw012";
303303+304304+ let key1 = generate_acceptance_record_key(rsvp1);
305305+ let key2 = generate_acceptance_record_key(rsvp2);
306306+307307+ // Different inputs should produce different outputs
308308+ assert_ne!(key1, key2);
309309+ }
310310+311311+ #[test]
312312+ fn test_generate_acceptance_record_key_format() {
313313+ let rsvp_aturi = "at://did:plc:test/community.lexicon.calendar.rsvp/test123";
314314+ let key = generate_acceptance_record_key(rsvp_aturi);
315315+316316+ // Key should be lowercase base32 (no padding)
317317+ assert!(
318318+ key.chars()
319319+ .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
320320+ );
321321+ assert!(!key.contains('=')); // No padding
322322+323323+ // Should be reasonable length (13 chars for base32-encoded u64)
324324+ assert!(key.len() <= 20);
325325+ assert!(key.len() >= 10);
326326+ }
327327+328328+ #[test]
329329+ fn test_generate_acceptance_record_key_collision_resistance() {
330330+ // Test with very similar AT-URIs to ensure no easy collisions
331331+ let rsvp1 = "at://did:plc:test/community.lexicon.calendar.rsvp/abc123";
332332+ let rsvp2 = "at://did:plc:test/community.lexicon.calendar.rsvp/abc124";
333333+334334+ let key1 = generate_acceptance_record_key(rsvp1);
335335+ let key2 = generate_acceptance_record_key(rsvp2);
336336+337337+ assert_ne!(key1, key2);
338338+ }
339339+340340+ #[test]
341341+ fn test_generate_acceptance_record_key_consistency_across_operations() {
342342+ // Verify that accept and unaccept would target the same record
343343+ let rsvp_aturi = "at://did:plc:user123/community.lexicon.calendar.rsvp/rsvp123";
344344+345345+ let key_for_create = generate_acceptance_record_key(rsvp_aturi);
346346+ let key_for_delete = generate_acceptance_record_key(rsvp_aturi);
347347+348348+ // Keys must match to ensure delete targets the same record created by accept
349349+ assert_eq!(key_for_create, key_for_delete);
350350+ }
351351+352352+ #[test]
353353+ fn test_generate_acceptance_record_key_different_users() {
354354+ // Different users RSVPing to same event should have different acceptance keys
355355+ let rsvp1 = "at://did:plc:user1/community.lexicon.calendar.rsvp/rsvp123";
356356+ let rsvp2 = "at://did:plc:user2/community.lexicon.calendar.rsvp/rsvp123";
357357+358358+ let key1 = generate_acceptance_record_key(rsvp1);
359359+ let key2 = generate_acceptance_record_key(rsvp2);
360360+361361+ assert_ne!(key1, key2);
362362+ }
363363+}
···2020 middleware_i18n::Language,
2121 },
2222 storage::{
2323- acceptance::{acceptance_ticket_delete, acceptance_ticket_get},
2323+ acceptance::{
2424+ acceptance_ticket_delete_by_event_and_rsvp_did, acceptance_ticket_get,
2525+ acceptance_ticket_get_by_event_and_rsvp_did, rsvp_update_validated_at,
2626+ },
2427 event::rsvp_get_by_event_and_did,
2528 },
2629};
···4043) -> Result<impl IntoResponse, WebError> {
4144 let current_handle = auth.require("/finalize_acceptance")?;
42454343- // Get the acceptance ticket from storage
4444- let ticket = acceptance_ticket_get(&web_context.pool, &form.acceptance_aturi)
4646+ // Get the old acceptance ticket to determine which event this is for
4747+ // We need the event_aturi to look up the latest ticket (in case organizer re-accepted with new metadata)
4848+ let old_ticket = acceptance_ticket_get(&web_context.pool, &form.acceptance_aturi)
4549 .await
4650 .map_err(|e| anyhow!("Failed to get acceptance ticket: {}", e))?
4751 .ok_or_else(|| anyhow!("Acceptance ticket not found"))?;
48524953 // Verify the current user is the RSVP creator (recipient of the acceptance)
5050- if ticket.rsvp_did != current_handle.did {
5454+ if old_ticket.rsvp_did != current_handle.did {
5155 return Err(CommonError::NotAuthorized.into());
5256 }
5757+5858+ // Now fetch the LATEST acceptance ticket for this event and RSVP DID
5959+ // This handles the case where the organizer re-accepted with updated metadata
6060+ let ticket = acceptance_ticket_get_by_event_and_rsvp_did(
6161+ &web_context.pool,
6262+ &old_ticket.event_aturi,
6363+ ¤t_handle.did,
6464+ )
6565+ .await
6666+ .map_err(|e| anyhow!("Failed to get latest acceptance ticket: {}", e))?
6767+ .ok_or_else(|| anyhow!("Acceptance ticket not found"))?;
53685469 // Get the RSVP to verify it exists and get its aturi
5570 let rsvp = rsvp_get_by_event_and_did(&web_context.pool, &ticket.event_aturi, &ticket.rsvp_did)
···103118 };
104119105120 // Deserialize the RSVP record from storage
106106- let rsvp_record: Rsvp = serde_json::from_value(rsvp.record.0.clone())
121121+ let mut rsvp_record: Rsvp = serde_json::from_value(rsvp.record.0.clone())
107122 .map_err(|e| anyhow!("Failed to deserialize RSVP record: {}", e))?;
108123109109- // Create a base RSVP without signatures for attestation
110110- let base_rsvp = Rsvp {
111111- subject: rsvp_record.subject.clone(),
112112- status: rsvp_record.status.clone(),
113113- created_at: rsvp_record.created_at,
114114- extra: rsvp_record.extra.clone(),
115115- signatures: vec![], // Clear signatures before appending
116116- };
117117- let typed_base_rsvp = TypedLexicon::new(base_rsvp);
124124+ // Remove any existing acceptance signatures from this organizer before adding the new one
125125+ // This prevents accumulation of stale signatures when an organizer re-accepts with updated metadata
126126+ // We identify signatures by matching on Reference signatures and checking the DID in the AT-URI
127127+ rsvp_record.signatures.retain(|sig| {
128128+ use atproto_record::lexicon::community::lexicon::attestation::SignatureOrRef;
129129+ match sig {
130130+ SignatureOrRef::Reference(strongref) => {
131131+ // Remove signatures from this organizer
132132+ // URI format: at://[did]/[collection]/[rkey]
133133+ // Check if the URI starts with the organizer's DID
134134+ let organizer_prefix = format!("at://{}/", ticket.did);
135135+ !strongref.uri.starts_with(&organizer_prefix)
136136+ }
137137+ SignatureOrRef::Inline(_) => {
138138+ // Keep all inline signatures
139139+ true
140140+ }
141141+ }
142142+ });
118143119119- // Append the remote attestation (acceptance) to the RSVP
144144+ // Now use the RSVP with old organizer signatures removed
145145+ // This ensures we only have one acceptance signature per organizer
146146+ let typed_base_rsvp = TypedLexicon::new(rsvp_record.clone());
147147+148148+ // Append the new remote attestation (acceptance) to the RSVP
149149+ // This will add the new signature from the organizer
120150 let updated_rsvp_record = append_remote_attestation(
121151 typed_base_rsvp.into(),
122152 acceptance_record.into(), // Convert to AnyInput
···172202 }
173203 };
174204175175- // Delete the acceptance ticket from storage (cleanup)
176176- acceptance_ticket_delete(&web_context.pool, &ticket.aturi)
205205+ // Update the RSVP validated_at timestamp to mark it as finalized
206206+ rsvp_update_validated_at(&web_context.pool, &rsvp.aturi, Some(chrono::Utc::now()))
177207 .await
178178- .map_err(|e| anyhow!("Failed to delete acceptance ticket: {}", e))?;
208208+ .map_err(|e| anyhow!("Failed to update RSVP validated_at: {}", e))?;
209209+210210+ // Delete ALL acceptance tickets for this event+rsvp_did combination (cleanup)
211211+ // This handles cases where there might be multiple tickets due to re-acceptance
212212+ acceptance_ticket_delete_by_event_and_rsvp_did(
213213+ &web_context.pool,
214214+ &ticket.event_aturi,
215215+ ¤t_handle.did,
216216+ )
217217+ .await
218218+ .map_err(|e| anyhow!("Failed to delete acceptance ticket: {}", e))?;
179219180220 // Return success with HTMX-compatible HTML
181221 Ok((
+510
src/http/handle_manage_event.rs
···11+use anyhow::Result;
22+use axum::{extract::Path, extract::Query, response::IntoResponse};
33+use axum_htmx::{HxBoosted, HxRequest};
44+use axum_template::RenderHtml;
55+use http::StatusCode;
66+use minijinja::context as template_context;
77+use serde::Deserialize;
88+99+use crate::{
1010+ contextual_error,
1111+ http::context::UserRequestContext,
1212+ http::errors::{CommonError, WebError},
1313+ http::event_form::{
1414+ BuildEventContentState, BuildEventForm, BuildLocationForm, BuildStartsForm,
1515+ },
1616+ http::location_edit_status::check_location_edit_status,
1717+ http::timezones::supported_timezones,
1818+ select_template,
1919+ storage::{
2020+ acceptance::acceptance_ticket_list_by_event,
2121+ event::{count_event_rsvps, event_get, get_event_rsvps_for_export},
2222+ identity_profile::{handle_for_did, handle_for_handle},
2323+ },
2424+};
2525+use atproto_record::lexicon::community::lexicon::calendar::event::{
2626+ Event as LexiconCommunityEvent, Mode, NSID as LexiconCommunityEventNSID, Status,
2727+};
2828+2929+#[derive(Debug, Deserialize)]
3030+pub struct ManageEventQuery {
3131+ #[serde(default = "default_tab")]
3232+ tab: String,
3333+}
3434+3535+fn default_tab() -> String {
3636+ "details".to_string()
3737+}
3838+3939+pub(crate) async fn handle_manage_event(
4040+ ctx: UserRequestContext,
4141+ HxBoosted(hx_boosted): HxBoosted,
4242+ HxRequest(hx_request): HxRequest,
4343+ Path((handle_slug, event_rkey)): Path<(String, String)>,
4444+ Query(query): Query<ManageEventQuery>,
4545+) -> Result<impl IntoResponse, WebError> {
4646+ let current_handle = ctx
4747+ .auth
4848+ .require(&format!("/{}/{}/manage", handle_slug, event_rkey))?;
4949+5050+ let default_context = template_context! {
5151+ current_handle,
5252+ language => ctx.language.to_string(),
5353+ canonical_url => format!("https://{}/{}/{}/manage", ctx.web_context.config.external_base, handle_slug, event_rkey),
5454+ };
5555+5656+ let error_template = select_template!(hx_boosted, hx_request, ctx.language);
5757+5858+ // Lookup the event
5959+ let profile = if handle_slug.starts_with("did:") {
6060+ handle_for_did(&ctx.web_context.pool, &handle_slug)
6161+ .await
6262+ .map_err(WebError::from)
6363+ } else {
6464+ let handle = if let Some(handle) = handle_slug.strip_prefix('@') {
6565+ handle
6666+ } else {
6767+ &handle_slug
6868+ };
6969+ handle_for_handle(&ctx.web_context.pool, handle)
7070+ .await
7171+ .map_err(WebError::from)
7272+ }?;
7373+7474+ let lookup_aturi = format!(
7575+ "at://{}/{}/{}",
7676+ profile.did, LexiconCommunityEventNSID, event_rkey
7777+ );
7878+7979+ // Check if the user is authorized to manage this event (must be the creator)
8080+ if profile.did != current_handle.did {
8181+ return contextual_error!(
8282+ ctx.web_context,
8383+ ctx.language,
8484+ error_template,
8585+ default_context,
8686+ CommonError::NotAuthorized,
8787+ StatusCode::FORBIDDEN
8888+ );
8989+ }
9090+9191+ let event = event_get(&ctx.web_context.pool, &lookup_aturi).await;
9292+ if let Err(err) = event {
9393+ return contextual_error!(
9494+ ctx.web_context,
9595+ ctx.language,
9696+ error_template,
9797+ default_context,
9898+ err,
9999+ StatusCode::NOT_FOUND
100100+ );
101101+ }
102102+103103+ let event = event.unwrap();
104104+105105+ // Fetch RSVP counts for all statuses
106106+ let going_count = count_event_rsvps(&ctx.web_context.pool, &lookup_aturi, "going")
107107+ .await
108108+ .unwrap_or_default();
109109+ let interested_count = count_event_rsvps(&ctx.web_context.pool, &lookup_aturi, "interested")
110110+ .await
111111+ .unwrap_or_default();
112112+ let notgoing_count = count_event_rsvps(&ctx.web_context.pool, &lookup_aturi, "notgoing")
113113+ .await
114114+ .unwrap_or_default();
115115+116116+ // Create a simple struct to hold counts for template
117117+ #[derive(serde::Serialize)]
118118+ struct RsvpCounts {
119119+ going: u32,
120120+ interested: u32,
121121+ notgoing: u32,
122122+ }
123123+124124+ let rsvp_counts = RsvpCounts {
125125+ going: going_count,
126126+ interested: interested_count,
127127+ notgoing: notgoing_count,
128128+ };
129129+130130+ // Fetch RSVPs for the event (for attendees and export tabs)
131131+ let mut rsvps = get_event_rsvps_for_export(&ctx.web_context.pool, &lookup_aturi)
132132+ .await
133133+ .unwrap_or_else(|err| {
134134+ tracing::warn!(?err, "Failed to fetch RSVPs for event");
135135+ Vec::new()
136136+ });
137137+138138+ let delete_event_url = format!(
139139+ "https://{}/{}/{}/delete",
140140+ ctx.web_context.config.external_base, handle_slug, event_rkey
141141+ );
142142+143143+ let is_development = cfg!(debug_assertions);
144144+145145+ // Determine which tab template to use
146146+ let (tab_template, active_tab) = match query.tab.as_str() {
147147+ "attendees" => ("en-us/manage_event_attendees_tab.html", "attendees"),
148148+ "content" => ("en-us/manage_event_content_tab.html", "content"),
149149+ "export" => ("en-us/manage_event_export_tab.html", "export"),
150150+ "settings" => ("en-us/manage_event_settings_tab.html", "settings"),
151151+ _ => ("en-us/manage_event_details_tab.html", "details"),
152152+ };
153153+154154+ // For attendees tab, enrich RSVPs with acceptance data
155155+ let accepted_count = if active_tab == "attendees" {
156156+ // Fetch acceptance tickets for this event
157157+ let acceptance_tickets =
158158+ acceptance_ticket_list_by_event(&ctx.web_context.pool, &lookup_aturi)
159159+ .await
160160+ .unwrap_or_else(|err| {
161161+ tracing::warn!(?err, "Failed to fetch acceptance tickets for event");
162162+ Vec::new()
163163+ });
164164+165165+ // Create a map of rsvp_did -> acceptance ticket for quick lookup
166166+ let acceptance_map: std::collections::HashMap<String, _> = acceptance_tickets
167167+ .into_iter()
168168+ .map(|ticket| (ticket.rsvp_did.clone(), ticket))
169169+ .collect();
170170+171171+ // Enrich RSVPs with acceptance status
172172+ // An RSVP is considered "accepted" if either:
173173+ // 1. An acceptance ticket exists (organizer accepted), OR
174174+ // 2. The RSVP has been validated (attendee finalized acceptance)
175175+ for rsvp in &mut rsvps {
176176+ if acceptance_map.contains_key(&rsvp.did) || rsvp.validated_at.is_some() {
177177+ rsvp.is_accepted = Some(true);
178178+ }
179179+ }
180180+181181+ // Sort RSVPs alphabetically by handle (None values go at the end)
182182+ rsvps.sort_by(|a, b| match (&a.handle, &b.handle) {
183183+ (Some(h_a), Some(h_b)) => h_a.to_lowercase().cmp(&h_b.to_lowercase()),
184184+ (Some(_), None) => std::cmp::Ordering::Less,
185185+ (None, Some(_)) => std::cmp::Ordering::Greater,
186186+ (None, None) => a.did.cmp(&b.did),
187187+ });
188188+189189+ // Count accepted RSVPs
190190+ rsvps.iter().filter(|r| r.is_accepted == Some(true)).count()
191191+ } else {
192192+ 0
193193+ };
194194+195195+ // Prepare form data for details and content tabs
196196+ let mut build_event_form = BuildEventForm {
197197+ build_state: None,
198198+ name: None,
199199+ name_error: None,
200200+ description: None,
201201+ description_error: None,
202202+ status: None,
203203+ status_error: None,
204204+ starts_at: None,
205205+ starts_at_error: None,
206206+ ends_at: None,
207207+ ends_at_error: None,
208208+ mode: None,
209209+ mode_error: None,
210210+ location_country: None,
211211+ location_country_error: None,
212212+ location_street: None,
213213+ location_street_error: None,
214214+ location_locality: None,
215215+ location_locality_error: None,
216216+ location_region: None,
217217+ location_region_error: None,
218218+ location_postal_code: None,
219219+ location_postal_code_error: None,
220220+ location_name: None,
221221+ location_name_error: None,
222222+ require_confirmed_email: None,
223223+ send_notifications: None,
224224+ private_content: None,
225225+ private_content_criteria_going_confirmed: None,
226226+ private_content_criteria_going: None,
227227+ private_content_criteria_interested: None,
228228+ };
229229+230230+ // Initialize timezone and form helpers (needed for details tab)
231231+ let (default_tz, timezones) = supported_timezones(ctx.current_handle.as_ref());
232232+ let parsed_tz = default_tz
233233+ .parse::<chrono_tz::Tz>()
234234+ .unwrap_or(chrono_tz::UTC);
235235+236236+ // Load event data for the details and content tabs
237237+ let (starts_form, location_form, locations_editable, location_edit_reason) = if active_tab
238238+ == "details"
239239+ || active_tab == "content"
240240+ {
241241+ let community_event =
242242+ match serde_json::from_value::<LexiconCommunityEvent>(event.record.0.clone()) {
243243+ Ok(event) => event,
244244+ Err(_) => {
245245+ return contextual_error!(
246246+ ctx.web_context,
247247+ ctx.language,
248248+ error_template,
249249+ default_context,
250250+ CommonError::InvalidEventFormat,
251251+ StatusCode::BAD_REQUEST
252252+ );
253253+ }
254254+ };
255255+256256+ // Populate form with event data
257257+ let LexiconCommunityEvent {
258258+ name,
259259+ description,
260260+ status,
261261+ mode,
262262+ starts_at,
263263+ ends_at,
264264+ ..
265265+ } = &community_event;
266266+267267+ build_event_form.name = Some(name.clone());
268268+ build_event_form.description = Some(description.clone());
269269+ build_event_form.require_confirmed_email = Some(event.require_confirmed_email);
270270+271271+ // Convert status enum to string
272272+ if let Some(status_val) = status {
273273+ build_event_form.status = Some(
274274+ match status_val {
275275+ Status::Planned => "planned",
276276+ Status::Scheduled => "scheduled",
277277+ Status::Cancelled => "cancelled",
278278+ Status::Postponed => "postponed",
279279+ Status::Rescheduled => "rescheduled",
280280+ }
281281+ .to_string(),
282282+ );
283283+ }
284284+285285+ // Convert mode enum to string
286286+ if let Some(mode_val) = mode {
287287+ build_event_form.mode = Some(
288288+ match mode_val {
289289+ Mode::InPerson => "inperson",
290290+ Mode::Virtual => "virtual",
291291+ Mode::Hybrid => "hybrid",
292292+ }
293293+ .to_string(),
294294+ );
295295+ }
296296+297297+ // Initialize starts_form
298298+ let mut starts_form = BuildStartsForm::from(build_event_form.clone());
299299+ starts_form.build_state = Some(BuildEventContentState::Selected);
300300+ if starts_form.tz.is_none() {
301301+ starts_form.tz = Some(default_tz.to_string());
302302+ }
303303+304304+ // Set date/time fields
305305+ if let Some(start_time) = starts_at {
306306+ let local_dt = start_time.with_timezone(&parsed_tz);
307307+ starts_form.starts_date = Some(local_dt.format("%Y-%m-%d").to_string());
308308+ starts_form.starts_time = Some(local_dt.format("%H:%M").to_string());
309309+ starts_form.starts_at = Some(start_time.to_string());
310310+ starts_form.starts_display = Some(local_dt.format("%A, %B %-d, %Y %r %Z").to_string());
311311+ build_event_form.starts_at = starts_form.starts_at.clone();
312312+ } else {
313313+ starts_form.starts_display = Some("--".to_string());
314314+ }
315315+316316+ if let Some(end_time) = ends_at {
317317+ let local_dt = end_time.with_timezone(&parsed_tz);
318318+ starts_form.include_ends = Some(true);
319319+ starts_form.ends_date = Some(local_dt.format("%Y-%m-%d").to_string());
320320+ starts_form.ends_time = Some(local_dt.format("%H:%M").to_string());
321321+ starts_form.ends_at = Some(end_time.to_string());
322322+ starts_form.ends_display = Some(local_dt.format("%A, %B %-d, %Y %r %Z").to_string());
323323+ build_event_form.ends_at = starts_form.ends_at.clone();
324324+ } else {
325325+ starts_form.ends_display = Some("--".to_string());
326326+ }
327327+328328+ // Initialize location_form
329329+ let mut location_form = BuildLocationForm::from(build_event_form.clone());
330330+ location_form.build_state = Some(BuildEventContentState::Selected);
331331+332332+ // Check location edit status
333333+ let location_edit_status = check_location_edit_status(&community_event.locations);
334334+ let locations_editable = location_edit_status.is_editable();
335335+ let location_edit_reason = location_edit_status.edit_reason();
336336+337337+ // If we have a single editable address location, populate the form fields
338338+ if let crate::http::location_edit_status::LocationEditStatus::Editable(address) =
339339+ &location_edit_status
340340+ {
341341+ build_event_form.location_country = Some(address.country.clone());
342342+ build_event_form.location_postal_code = address.postal_code.clone();
343343+ build_event_form.location_region = address.region.clone();
344344+ build_event_form.location_locality = address.locality.clone();
345345+ build_event_form.location_street = address.street.clone();
346346+ build_event_form.location_name = address.name.clone();
347347+348348+ location_form.location_country = Some(address.country.clone());
349349+ location_form.location_postal_code = address.postal_code.clone();
350350+ location_form.location_region = address.region.clone();
351351+ location_form.location_locality = address.locality.clone();
352352+ location_form.location_street = address.street.clone();
353353+ location_form.location_name = address.name.clone();
354354+ }
355355+356356+ build_event_form.build_state = Some(BuildEventContentState::Selected);
357357+358358+ // Load private event content if it exists
359359+ if let Ok(Some(private_content)) =
360360+ crate::storage::private_event_content::private_event_content_get(
361361+ &ctx.web_context.pool,
362362+ &lookup_aturi,
363363+ )
364364+ .await
365365+ {
366366+ build_event_form.private_content = Some(private_content.content);
367367+368368+ // Set checkboxes based on criteria
369369+ for criterion in private_content.display_criteria.0.iter() {
370370+ match criterion.as_str() {
371371+ "going_confirmed" => {
372372+ build_event_form.private_content_criteria_going_confirmed = Some(true)
373373+ }
374374+ "going" => build_event_form.private_content_criteria_going = Some(true),
375375+ "interested" => {
376376+ build_event_form.private_content_criteria_interested = Some(true)
377377+ }
378378+ _ => {}
379379+ }
380380+ }
381381+ }
382382+383383+ (
384384+ starts_form,
385385+ location_form,
386386+ locations_editable,
387387+ location_edit_reason,
388388+ )
389389+ } else {
390390+ // For other tabs, use default/empty forms
391391+ let starts_form = BuildStartsForm {
392392+ build_state: Some(BuildEventContentState::default()),
393393+ tz: Some(default_tz.to_string()),
394394+ tz_error: None,
395395+ starts_date: None,
396396+ starts_date_error: None,
397397+ starts_time: None,
398398+ starts_time_error: None,
399399+ ends_date: None,
400400+ ends_date_error: None,
401401+ ends_time: None,
402402+ ends_time_error: None,
403403+ include_ends: None,
404404+ starts_at: None,
405405+ ends_at: None,
406406+ starts_at_error: None,
407407+ ends_at_error: None,
408408+ starts_display: None,
409409+ ends_display: None,
410410+ };
411411+412412+ let location_form = BuildLocationForm {
413413+ build_state: Some(BuildEventContentState::default()),
414414+ location_country: None,
415415+ location_country_error: None,
416416+ location_street: None,
417417+ location_street_error: None,
418418+ location_locality: None,
419419+ location_locality_error: None,
420420+ location_region: None,
421421+ location_region_error: None,
422422+ location_postal_code: None,
423423+ location_postal_code_error: None,
424424+ location_name: None,
425425+ location_name_error: None,
426426+ };
427427+428428+ (starts_form, location_form, true, None)
429429+ };
430430+431431+ // Common template variables needed by create_event.partial.html
432432+ let submit_url = format!("/{}/{}/edit", handle_slug, event_rkey);
433433+ let cancel_url = format!("/{}/{}/manage", handle_slug, event_rkey);
434434+ let create_event = false;
435435+436436+ // If this is an HTMX request, render tabs + content together for active state updates
437437+ if hx_request {
438438+ return Ok((
439439+ StatusCode::OK,
440440+ RenderHtml(
441441+ "en-us/manage_event_tabs_and_content.partial.html",
442442+ ctx.web_context.engine.clone(),
443443+ template_context! {
444444+ current_handle => ctx.current_handle,
445445+ language => ctx.language.to_string(),
446446+ handle_slug,
447447+ event_rkey,
448448+ event,
449449+ rsvps,
450450+ rsvp_counts,
451451+ accepted_count,
452452+ going_count,
453453+ interested_count,
454454+ build_event_form,
455455+ starts_form,
456456+ location_form,
457457+ timezones,
458458+ locations_editable,
459459+ location_edit_reason,
460460+ delete_event_url,
461461+ is_development,
462462+ active_tab,
463463+ tab_template,
464464+ submit_url,
465465+ cancel_url,
466466+ create_event,
467467+ },
468468+ ),
469469+ )
470470+ .into_response());
471471+ }
472472+473473+ // Render full page with selected tab
474474+ let render_template = select_template!("manage_event", hx_boosted, hx_request, ctx.language);
475475+476476+ Ok((
477477+ StatusCode::OK,
478478+ RenderHtml(
479479+ &render_template,
480480+ ctx.web_context.engine.clone(),
481481+ template_context! {
482482+ current_handle => ctx.current_handle,
483483+ language => ctx.language.to_string(),
484484+ canonical_url => format!("https://{}/{}/{}/manage", ctx.web_context.config.external_base, handle_slug, event_rkey),
485485+ handle_slug,
486486+ event_rkey,
487487+ event,
488488+ rsvps,
489489+ rsvp_counts,
490490+ accepted_count,
491491+ going_count,
492492+ interested_count,
493493+ build_event_form,
494494+ starts_form,
495495+ location_form,
496496+ timezones,
497497+ locations_editable,
498498+ location_edit_reason,
499499+ delete_event_url,
500500+ is_development,
501501+ active_tab,
502502+ tab_template,
503503+ submit_url,
504504+ cancel_url,
505505+ create_event,
506506+ },
507507+ ),
508508+ )
509509+ .into_response())
510510+}
···219219 );
220220221221 if hx_request {
222222- let hx_redirect = HxRedirect::try_from(destination.as_str())
223223- .expect("HxRedirect construction should not fail");
222222+ let hx_redirect = HxRedirect::from(destination.as_str());
224223 return Ok((StatusCode::OK, hx_redirect, "").into_response());
225224 }
226225
···11+pub mod acceptance_operations;
12pub mod acceptance_utils;
23pub mod auth_utils;
34pub mod cache_countries;
···1617pub mod handle_admin_rsvp;
1718pub mod handle_admin_rsvps;
1819pub mod handle_blob;
2020+pub mod handle_bulk_accept_rsvps;
1921pub mod handle_content;
2022pub mod handle_create_event;
2123pub mod handle_create_rsvp;
···3032pub mod handle_import;
3133pub mod handle_index;
3234pub mod handle_mailgun_webhook;
3535+pub mod handle_manage_event;
3636+pub mod handle_manage_event_content;
3337pub mod handle_oauth_aip_callback;
3438pub mod handle_oauth_aip_login;
3539pub mod handle_oauth_callback;
···3943pub mod handle_profile;
4044pub mod handle_set_language;
4145pub mod handle_settings;
4646+pub mod handle_unaccept_rsvp;
4247pub mod handle_unsubscribe;
4348pub mod handle_view_event;
4449pub mod handle_wellknown;
···2929}
30303131/// Insert or update an acceptance ticket
3232+/// When inserting a new ticket, old tickets for the same event+rsvp_did are deleted
3333+/// to prevent accumulation of stale tickets (e.g., when organizer re-accepts with new metadata)
3234pub async fn acceptance_ticket_upsert<T: serde::Serialize>(
3335 pool: &StoragePool,
3436 aturi: &str,
···44464547 let now = Utc::now();
46484949+ // First, delete any existing tickets for this event+rsvp_did combination
5050+ // This handles the case where an organizer re-accepts with updated metadata (new CID)
5151+ sqlx::query(
5252+ "DELETE FROM acceptance_tickets WHERE event_aturi = $1 AND rsvp_did = $2 AND aturi != $3",
5353+ )
5454+ .bind(event_aturi)
5555+ .bind(rsvp_did)
5656+ .bind(aturi)
5757+ .execute(tx.as_mut())
5858+ .await
5959+ .map_err(StorageError::UnableToExecuteQuery)?;
6060+6161+ // Now insert or update the current ticket
4762 sqlx::query(
4863 "INSERT INTO acceptance_tickets (aturi, did, rsvp_did, event_aturi, record, created_at)
4964 VALUES ($1, $2, $3, $4, $5, $6)
···286301}
287302288303/// Get acceptance ticket for a specific event and RSVP DID combination
304304+/// Returns the most recent ticket if multiple exist (e.g., after re-acceptance with updated metadata)
289305pub async fn acceptance_ticket_get_by_event_and_rsvp_did(
290306 pool: &StoragePool,
291307 event_aturi: &str,
···297313 .map_err(StorageError::CannotBeginDatabaseTransaction)?;
298314299315 let record = sqlx::query_as::<_, AcceptanceTicket>(
300300- "SELECT * FROM acceptance_tickets WHERE event_aturi = $1 AND rsvp_did = $2 LIMIT 1",
316316+ "SELECT * FROM acceptance_tickets WHERE event_aturi = $1 AND rsvp_did = $2 ORDER BY created_at DESC LIMIT 1",
301317 )
302318 .bind(event_aturi)
303319 .bind(rsvp_did)
···312328 Ok(record)
313329}
314330331331+/// Delete acceptance ticket by event and RSVP DID
332332+pub async fn acceptance_ticket_delete_by_event_and_rsvp_did(
333333+ pool: &StoragePool,
334334+ event_aturi: &str,
335335+ rsvp_did: &str,
336336+) -> Result<(), StorageError> {
337337+ let mut tx = pool
338338+ .begin()
339339+ .await
340340+ .map_err(StorageError::CannotBeginDatabaseTransaction)?;
341341+342342+ sqlx::query("DELETE FROM acceptance_tickets WHERE event_aturi = $1 AND rsvp_did = $2")
343343+ .bind(event_aturi)
344344+ .bind(rsvp_did)
345345+ .execute(tx.as_mut())
346346+ .await
347347+ .map_err(StorageError::UnableToExecuteQuery)?;
348348+349349+ tx.commit()
350350+ .await
351351+ .map_err(StorageError::CannotCommitDatabaseTransaction)
352352+}
353353+315354/// Update RSVP validated_at timestamp
316355pub async fn rsvp_update_validated_at(
317356 pool: &StoragePool,
···334373 .await
335374 .map_err(StorageError::CannotCommitDatabaseTransaction)
336375}
376376+377377+/// Clear validated_at timestamp for all RSVPs of a given event
378378+/// This should be called when event details change to invalidate existing validations
379379+pub async fn rsvp_clear_event_validations(
380380+ pool: &StoragePool,
381381+ event_aturi: &str,
382382+) -> Result<(), StorageError> {
383383+ let mut tx = pool
384384+ .begin()
385385+ .await
386386+ .map_err(StorageError::CannotBeginDatabaseTransaction)?;
387387+388388+ let result = sqlx::query("UPDATE rsvps SET validated_at = NULL WHERE event_aturi = $1")
389389+ .bind(event_aturi)
390390+ .execute(tx.as_mut())
391391+ .await
392392+ .map_err(StorageError::UnableToExecuteQuery)?;
393393+394394+ tracing::info!(
395395+ event_aturi = ?event_aturi,
396396+ rows_affected = result.rows_affected(),
397397+ "Cleared RSVP validations for event"
398398+ );
399399+400400+ tx.commit()
401401+ .await
402402+ .map_err(StorageError::CannotCommitDatabaseTransaction)
403403+}
···11+{# Tabs and Content Container - for HTMX swaps #}
22+{# This ensures both tabs and content are updated together so active states work correctly #}
33+44+{# Tab Navigation #}
55+{% include 'en-us/manage_event_tabs.partial.html' %}
66+77+{# Tab Content Container #}
88+<div id="tab-content">
99+ {% include tab_template %}
1010+</div>