use atproto_attestation::append_remote_attestation; use atproto_record::aturi::ATURI; use atproto_record::lexicon::community::lexicon::calendar::rsvp::{ NSID as RSVP_NSID, Rsvp, RsvpStatus, }; use atproto_record::typed::TypedLexicon; use axum::{extract::State, response::IntoResponse}; use axum_extra::extract::{Cached, Form}; use chrono::Utc; use http::StatusCode; use metrohash::MetroHash64; use serde::Deserialize; use std::hash::Hasher; use std::str::FromStr; use crate::{ atproto::auth::create_dpop_auth_from_session, http::{ acceptance_utils::format_success_html, context::WebContext, errors::{WebError, acceptance_error::AcceptanceError}, middleware_auth::Auth, middleware_i18n::Language, }, storage::{ acceptance::{ acceptance_ticket_delete_by_event_and_rsvp_did, acceptance_ticket_get, acceptance_ticket_get_by_event_and_rsvp_did, rsvp_update_validated_at, }, event::{ RsvpInsertParams, event_get, rsvp_get_by_event_and_did, rsvp_insert_with_metadata, }, }, }; use atproto_client::com::atproto::repo::{PutRecordRequest, PutRecordResponse, put_record}; #[derive(Debug, Deserialize)] pub struct FinalizeAcceptanceForm { /// The AT-URI of the acceptance ticket to finalize acceptance_aturi: String, } pub(crate) async fn handle_finalize_acceptance( State(web_context): State, Language(_language): Language, Cached(auth): Cached, Form(form): Form, ) -> Result { let current_handle = auth.require("/finalize_acceptance")?; let session = auth.session().ok_or(AcceptanceError::NotAuthorized)?; // Get the old acceptance ticket to determine which event this is for // We need the event_aturi to look up the latest ticket (in case organizer re-accepted with new metadata) let old_ticket = acceptance_ticket_get(&web_context.pool, &form.acceptance_aturi) .await .map_err(|e| AcceptanceError::TicketGetFailed(e.to_string()))? .ok_or(AcceptanceError::TicketNotFound)?; // Verify the current user is the RSVP creator (recipient of the acceptance) if old_ticket.rsvp_did != current_handle.did { return Err(AcceptanceError::NotAuthorized.into()); } // Now fetch the LATEST acceptance ticket for this event and RSVP DID // This handles the case where the organizer re-accepted with updated metadata let ticket = acceptance_ticket_get_by_event_and_rsvp_did( &web_context.pool, &old_ticket.event_aturi, ¤t_handle.did, ) .await .map_err(|e| AcceptanceError::LatestTicketGetFailed(e.to_string()))? .ok_or(AcceptanceError::TicketNotFound)?; // Check if this is an external attestation (from linkAttestation XRPC) let is_external_attestation = ticket .record .get("$type") .and_then(|v| v.as_str()) .map(|t| t == "events.smokesignal.calendar.externalAttestation") .unwrap_or(false); // Get the RSVP - may or may not exist let existing_rsvp = rsvp_get_by_event_and_did(&web_context.pool, &ticket.event_aturi, &ticket.rsvp_did) .await .map_err(|e| AcceptanceError::RsvpGetFailed(e.to_string()))?; // Handle different cases based on whether RSVP exists and attestation type // Return type is (record as Value, rsvp_aturi, is_new_rsvp, event_cid) let (updated_rsvp_record, rsvp_aturi, is_new_rsvp, event_cid): ( serde_json::Value, String, bool, String, ) = if is_external_attestation { // External attestation flow - may need to create new RSVP if let Some(rsvp) = existing_rsvp { // RSVP exists - user may have created it separately // Just mark as validated, don't add signatures (external attestation isn't a real AT Protocol record) return handle_external_attestation_with_existing_rsvp( &web_context, ticket, rsvp, ¤t_handle.did, ) .await; } // No existing RSVP - create one for the user // Get event details for the RSVP subject let event = event_get(&web_context.pool, &ticket.event_aturi) .await .map_err(|e| AcceptanceError::EventGetFailed(e.to_string()))?; let now = Utc::now(); // Create subject reference let subject = atproto_record::lexicon::com::atproto::repo::StrongRef { uri: ticket.event_aturi.clone(), cid: event.cid.clone(), }; // Generate record key from event URI let mut h = MetroHash64::default(); h.write(ticket.event_aturi.as_bytes()); let record_key = crockford::encode(h.finish()); // Create RSVP record with "going" status // For external attestations, we don't add to signatures since the attestation // isn't a real AT Protocol record we can reference let new_rsvp = Rsvp { created_at: now, subject, status: RsvpStatus::Going, signatures: vec![], // External attestations aren't added to signatures extra: Default::default(), }; let rsvp_aturi = format!("at://{}/{}/{}", current_handle.did, RSVP_NSID, record_key); // Convert Rsvp to Value for consistent return type let rsvp_value = serde_json::to_value(&new_rsvp) .map_err(|e| AcceptanceError::RsvpDeserializeFailed(e.to_string()))?; (rsvp_value, rsvp_aturi, true, event.cid) } else { // Standard acceptance flow - RSVP must exist let rsvp = existing_rsvp.ok_or(AcceptanceError::RsvpNotFound)?; // Parse the acceptance AT-URI to fetch it from the organizer's PDS let parsed_acceptance_aturi = ATURI::from_str(&ticket.aturi) .map_err(|e| AcceptanceError::InvalidAcceptanceAtUri(e.to_string()))?; // Resolve the organizer's DID to get their PDS endpoint let organizer_document = web_context .identity_resolver .resolve(&ticket.did) .await .map_err(|e| AcceptanceError::OrganizerDidResolveFailed(e.to_string()))?; let organizer_pds = organizer_document .service .iter() .find(|s| s.r#type == "AtprotoPersonalDataServer") .map(|s| s.service_endpoint.as_str()) .ok_or(AcceptanceError::OrganizerNoPdsEndpoint)?; // Fetch the acceptance record from the organizer's PDS let acceptance_record_resp = atproto_client::com::atproto::repo::get_record( &web_context.http_client, &atproto_client::client::Auth::None, organizer_pds, &parsed_acceptance_aturi.authority, &parsed_acceptance_aturi.collection, &parsed_acceptance_aturi.record_key, None, ) .await .map_err(|e| AcceptanceError::AcceptanceRecordGetFailed(e.to_string()))?; let acceptance_record = match acceptance_record_resp { atproto_client::com::atproto::repo::GetRecordResponse::Record { value, .. } => value, atproto_client::com::atproto::repo::GetRecordResponse::Error(error) => { return Err(AcceptanceError::AcceptanceRecordGetFailed( error.error_message().to_string(), ) .into()); } }; // Deserialize the RSVP record from storage let mut rsvp_record: Rsvp = serde_json::from_value(rsvp.record.0.clone()) .map_err(|e| AcceptanceError::RsvpDeserializeFailed(e.to_string()))?; // Remove any existing acceptance signatures from this organizer before adding the new one // This prevents accumulation of stale signatures when an organizer re-accepts with updated metadata // We identify signatures by matching on Reference signatures and checking the DID in the AT-URI rsvp_record.signatures.retain(|sig| { use atproto_record::lexicon::community::lexicon::attestation::SignatureOrRef; match sig { SignatureOrRef::Reference(strongref) => { // Remove signatures from this organizer // URI format: at://[did]/[collection]/[rkey] // Check if the URI starts with the organizer's DID let organizer_prefix = format!("at://{}/", ticket.did); !strongref.uri.starts_with(&organizer_prefix) } SignatureOrRef::Inline(_) => { // Keep all inline signatures true } } }); // Now use the RSVP with old organizer signatures removed // This ensures we only have one acceptance signature per organizer let typed_base_rsvp = TypedLexicon::new(rsvp_record.clone()); // Append the new remote attestation (acceptance) to the RSVP // This will add the new signature from the organizer let updated_record = append_remote_attestation( typed_base_rsvp.into(), acceptance_record.into(), // Convert to AnyInput &ticket.did, // organizer's DID (receiver of the RSVP, giver of acceptance) &ticket.aturi, // acceptance AT-URI ) .map_err(|e| AcceptanceError::AttestationAppendFailed(e.to_string()))?; // Get event CID from existing RSVP let event_cid = rsvp.event_cid.clone(); (updated_record, rsvp.aturi.clone(), false, event_cid) }; // Create DPoP auth from session let dpop_auth = create_dpop_auth_from_session(session)?; // Parse the RSVP AT-URI to extract the record key let parsed_rsvp_aturi = ATURI::from_str(&rsvp_aturi) .map_err(|e| AcceptanceError::InvalidRsvpAtUri(e.to_string()))?; // Update/Create the RSVP in the user's PDS let put_record_request = PutRecordRequest { repo: current_handle.did.clone(), collection: RSVP_NSID.to_string(), validate: false, record_key: parsed_rsvp_aturi.record_key.clone(), record: updated_rsvp_record.clone(), swap_commit: None, swap_record: None, }; let put_record_result = put_record( &web_context.http_client, &atproto_client::client::Auth::DPoP(dpop_auth), ¤t_handle.pds, put_record_request, ) .await; let updated_rsvp = match put_record_result { Ok(PutRecordResponse::StrongRef { uri, cid, .. }) => { atproto_record::lexicon::com::atproto::repo::StrongRef { uri, cid } } Ok(PutRecordResponse::Error(err)) => { return Err(AcceptanceError::RsvpUpdateAtProtocolFailed( err.error_message().to_string(), ) .into()); } Err(err) => { return Err(AcceptanceError::RsvpUpdateFailed(err.to_string()).into()); } }; // If this is a new RSVP (from external attestation), insert it into the database if is_new_rsvp { // Insert the new RSVP into the database let rsvp_insert_result = rsvp_insert_with_metadata( &web_context.pool, RsvpInsertParams { aturi: &updated_rsvp.uri, cid: &updated_rsvp.cid, did: ¤t_handle.did, lexicon: RSVP_NSID, record: &updated_rsvp_record, event_aturi: &ticket.event_aturi, event_cid: &event_cid, status: "going", clear_validated_at: false, }, ) .await; if let Err(err) = rsvp_insert_result { tracing::error!(?err, "Failed to insert new RSVP for external attestation"); return Err(AcceptanceError::RsvpInsertFailed(err.to_string()).into()); } } // Update the RSVP validated_at timestamp to mark it as finalized rsvp_update_validated_at(&web_context.pool, &updated_rsvp.uri, Some(Utc::now())) .await .map_err(|e| AcceptanceError::RsvpValidatedAtUpdateFailed(e.to_string()))?; // Delete ALL acceptance tickets for this event+rsvp_did combination (cleanup) // This handles cases where there might be multiple tickets due to re-acceptance acceptance_ticket_delete_by_event_and_rsvp_did( &web_context.pool, &ticket.event_aturi, ¤t_handle.did, ) .await .map_err(|e| AcceptanceError::TicketDeleteFailed(e.to_string()))?; // Return success with HTMX-compatible HTML let success_message = if is_new_rsvp { "Your ticket purchase has been confirmed and your RSVP has been created." } else { "Your RSVP has been updated with the organizer's acceptance." }; let success_details = if is_new_rsvp { vec![ "Your attendance is now confirmed.".to_string(), "You are now listed as going to this event.".to_string(), ] } else { vec![ "The acceptance signature has been added to your RSVP.".to_string(), "This RSVP is now verified.".to_string(), ] }; Ok(( StatusCode::OK, format_success_html( if is_new_rsvp { "Attendance confirmed" } else { "Acceptance finalized successfully" }, success_message, Some(success_details), ), ) .into_response()) } /// Helper function to handle external attestations when the user already has an RSVP. /// In this case, we just mark the RSVP as validated without modifying its signatures. async fn handle_external_attestation_with_existing_rsvp( web_context: &WebContext, ticket: crate::storage::acceptance::AcceptanceTicket, rsvp: crate::storage::event::model::Rsvp, current_handle_did: &str, ) -> Result { // Mark the existing RSVP as validated rsvp_update_validated_at(&web_context.pool, &rsvp.aturi, Some(Utc::now())) .await .map_err(|e| AcceptanceError::RsvpValidatedAtUpdateFailed(e.to_string()))?; // Delete the acceptance ticket acceptance_ticket_delete_by_event_and_rsvp_did( &web_context.pool, &ticket.event_aturi, current_handle_did, ) .await .map_err(|e| AcceptanceError::TicketDeleteFailed(e.to_string()))?; Ok(( StatusCode::OK, format_success_html( "Ticket verified", "Your ticket purchase has been verified and your existing RSVP is now confirmed.", Some(vec![ "Your attendance is now confirmed.".to_string(), "Your existing RSVP has been validated.".to_string(), ]), ), ) .into_response()) }