use axum::{ extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, routing::{delete, get, post, put}, Json, Router, }; use chrono::{DateTime, Duration, Utc}; use diesel::prelude::*; use diesel::sql_types::{BigInt, Bool, Integer, Nullable, Text, Timestamptz, Uuid as DieselUuid}; use log::{debug, error, info, warn}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::broadcast; use uuid::Uuid; use crate::shared::state::AppState; const MAX_WEBINAR_PARTICIPANTS: usize = 10000; const MAX_PRESENTERS: usize = 25; const MAX_RAISED_HANDS_VISIBLE: usize = 50; const QA_QUESTION_MAX_LENGTH: usize = 1000; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Webinar { pub id: Uuid, pub organization_id: Uuid, pub meeting_id: Uuid, pub title: String, pub description: Option, pub scheduled_start: DateTime, pub scheduled_end: Option>, pub actual_start: Option>, pub actual_end: Option>, pub status: WebinarStatus, pub settings: WebinarSettings, pub registration_required: bool, pub registration_url: Option, pub host_id: Uuid, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum WebinarStatus { Draft, Scheduled, Live, Paused, Ended, Cancelled, } impl std::fmt::Display for WebinarStatus { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Draft => write!(f, "draft"), Self::Scheduled => write!(f, "scheduled"), Self::Live => write!(f, "live"), Self::Paused => write!(f, "paused"), Self::Ended => write!(f, "ended"), Self::Cancelled => write!(f, "cancelled"), } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WebinarSettings { pub allow_attendee_video: bool, pub allow_attendee_audio: bool, pub allow_chat: bool, pub allow_qa: bool, pub allow_hand_raise: bool, pub allow_reactions: bool, pub moderated_qa: bool, pub anonymous_qa: bool, pub auto_record: bool, pub waiting_room_enabled: bool, pub max_attendees: u32, pub practice_session_enabled: bool, pub attendee_registration_fields: Vec, /// Enable automatic transcription during recording pub auto_transcribe: bool, /// Language for transcription (e.g., "en-US", "es-ES") pub transcription_language: Option, /// Enable speaker identification in transcription pub transcription_speaker_identification: bool, /// Store recording in cloud storage pub cloud_recording: bool, /// Recording quality setting pub recording_quality: RecordingQuality, } /// Recording quality settings #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] pub enum RecordingQuality { #[default] Standard, // 720p High, // 1080p Ultra, // 4K AudioOnly, // Audio only recording } impl std::fmt::Display for RecordingQuality { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { RecordingQuality::Standard => write!(f, "standard"), RecordingQuality::High => write!(f, "high"), RecordingQuality::Ultra => write!(f, "ultra"), RecordingQuality::AudioOnly => write!(f, "audio_only"), } } } impl Default for WebinarSettings { fn default() -> Self { Self { allow_attendee_video: false, allow_attendee_audio: false, allow_chat: true, allow_qa: true, allow_hand_raise: true, allow_reactions: true, moderated_qa: true, anonymous_qa: false, auto_record: false, waiting_room_enabled: true, max_attendees: MAX_WEBINAR_PARTICIPANTS as u32, practice_session_enabled: false, attendee_registration_fields: vec![ RegistrationField::required("name", FieldType::Text), RegistrationField::required("email", FieldType::Email), ], auto_transcribe: true, transcription_language: Some("en-US".to_string()), transcription_speaker_identification: true, cloud_recording: true, recording_quality: RecordingQuality::default(), } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RegistrationField { pub name: String, pub field_type: FieldType, pub required: bool, pub options: Option>, } impl RegistrationField { pub fn required(name: &str) -> Self { Self { name: name.to_string(), field_type: FieldType::Text, required: true, options: None, } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum FieldType { Text, Email, Phone, Select, Checkbox, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ParticipantRole { Host, CoHost, Presenter, Panelist, Attendee, } impl std::fmt::Display for ParticipantRole { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Host => write!(f, "host"), Self::CoHost => write!(f, "co_host"), Self::Presenter => write!(f, "presenter"), Self::Panelist => write!(f, "panelist"), Self::Attendee => write!(f, "attendee"), } } } impl ParticipantRole { pub fn can_present(&self) -> bool { matches!(self, Self::Host | Self::CoHost | Self::Presenter | Self::Panelist) } pub fn can_manage(&self) -> bool { matches!(self, Self::Host | Self::CoHost) } pub fn can_speak(&self) -> bool { matches!(self, Self::Host | Self::CoHost | Self::Presenter | Self::Panelist) } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WebinarParticipant { pub id: Uuid, pub webinar_id: Uuid, pub user_id: Option, pub name: String, pub email: Option, pub role: ParticipantRole, pub status: ParticipantStatus, pub hand_raised: bool, pub hand_raised_at: Option>, pub is_speaking: bool, pub video_enabled: bool, pub audio_enabled: bool, pub screen_sharing: bool, pub joined_at: Option>, pub left_at: Option>, pub registration_data: Option>, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ParticipantStatus { Registered, InWaitingRoom, Joined, Left, Removed, } impl std::fmt::Display for ParticipantStatus { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Registered => write!(f, "registered"), Self::InWaitingRoom => write!(f, "in_waiting_room"), Self::Joined => write!(f, "joined"), Self::Left => write!(f, "left"), Self::Removed => write!(f, "removed"), } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct QAQuestion { pub id: Uuid, pub webinar_id: Uuid, pub asker_id: Option, pub asker_name: String, pub is_anonymous: bool, pub question: String, pub status: QuestionStatus, pub upvotes: i32, pub upvoted_by: Vec, pub answer: Option, pub answered_by: Option, pub answered_at: Option>, pub is_pinned: bool, pub is_highlighted: bool, pub created_at: DateTime, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum QuestionStatus { Pending, Approved, Answered, Dismissed, AnsweredLive, } impl std::fmt::Display for QuestionStatus { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Pending => write!(f, "pending"), Self::Approved => write!(f, "approved"), Self::Answered => write!(f, "answered"), Self::Dismissed => write!(f, "dismissed"), Self::AnsweredLive => write!(f, "answered_live"), } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WebinarPoll { pub id: Uuid, pub webinar_id: Uuid, pub question: String, pub poll_type: PollType, pub options: Vec, pub status: PollStatus, pub show_results_to_attendees: bool, pub allow_multiple_answers: bool, pub created_by: Uuid, pub created_at: DateTime, pub launched_at: Option>, pub closed_at: Option>, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum PollType { SingleChoice, MultipleChoice, Rating, OpenEnded, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum PollStatus { Draft, Launched, Closed, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PollOption { pub id: Uuid, pub text: String, pub vote_count: i32, pub percentage: f32, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PollVote { pub poll_id: Uuid, pub participant_id: Uuid, pub option_ids: Vec, pub open_response: Option, pub voted_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WebinarRegistration { pub id: Uuid, pub webinar_id: Uuid, pub email: String, pub name: String, pub custom_fields: HashMap, pub status: RegistrationStatus, pub join_link: String, pub registered_at: DateTime, pub confirmed_at: Option>, pub cancelled_at: Option>, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum RegistrationStatus { Pending, Confirmed, Cancelled, Attended, NoShow, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WebinarAnalytics { pub webinar_id: Uuid, pub total_registrations: u32, pub total_attendees: u32, pub peak_attendees: u32, pub average_watch_time_seconds: u64, pub total_questions: u32, pub answered_questions: u32, pub total_reactions: u32, pub poll_participation_rate: f32, pub engagement_score: f32, pub attendee_retention: Vec, /// Recording information if available pub recording: Option, /// Transcription information if available pub transcription: Option, } /// Webinar recording information #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WebinarRecording { pub id: Uuid, pub webinar_id: Uuid, pub status: RecordingStatus, pub duration_seconds: u64, pub file_size_bytes: u64, pub file_url: Option, pub download_url: Option, pub quality: RecordingQuality, pub started_at: DateTime, pub ended_at: Option>, pub processed_at: Option>, pub expires_at: Option>, pub view_count: u32, pub download_count: u32, } /// Recording status #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum RecordingStatus { Recording, Processing, Ready, Failed, Deleted, Expired, } impl std::fmt::Display for RecordingStatus { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { RecordingStatus::Recording => write!(f, "recording"), RecordingStatus::Processing => write!(f, "processing"), RecordingStatus::Ready => write!(f, "ready"), RecordingStatus::Failed => write!(f, "failed"), RecordingStatus::Deleted => write!(f, "deleted"), RecordingStatus::Expired => write!(f, "expired"), } } } /// Webinar transcription information #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WebinarTranscription { pub id: Uuid, pub webinar_id: Uuid, pub recording_id: Uuid, pub status: TranscriptionStatus, pub language: String, pub duration_seconds: u64, pub word_count: u32, pub speaker_count: u32, pub segments: Vec, pub full_text: Option, pub vtt_url: Option, pub srt_url: Option, pub json_url: Option, pub created_at: DateTime, pub completed_at: Option>, pub confidence_score: f32, } /// Transcription status #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum TranscriptionStatus { Pending, InProgress, Completed, Failed, PartiallyCompleted, } impl std::fmt::Display for TranscriptionStatus { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { TranscriptionStatus::Pending => write!(f, "pending"), TranscriptionStatus::InProgress => write!(f, "in_progress"), TranscriptionStatus::Completed => write!(f, "completed"), TranscriptionStatus::Failed => write!(f, "failed"), TranscriptionStatus::PartiallyCompleted => write!(f, "partially_completed"), } } } /// A segment of transcription with timing and speaker info #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TranscriptionSegment { pub id: Uuid, pub start_time_ms: u64, pub end_time_ms: u64, pub text: String, pub speaker_id: Option, pub speaker_name: Option, pub confidence: f32, pub words: Vec, } /// Individual word in transcription with timing #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TranscriptionWord { pub word: String, pub start_time_ms: u64, pub end_time_ms: u64, pub confidence: f32, } /// Request to start recording #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StartRecordingRequest { pub quality: Option, pub enable_transcription: Option, pub transcription_language: Option, } /// Request to get transcription #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GetTranscriptionRequest { pub format: TranscriptionFormat, pub include_timestamps: bool, pub include_speaker_names: bool, } /// Transcription output format #[derive(Debug, Clone, Serialize, Deserialize)] pub enum TranscriptionFormat { PlainText, Vtt, Srt, Json, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RetentionPoint { pub minutes_from_start: i32, pub attendee_count: i32, pub percentage: f32, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreateWebinarRequest { pub title: String, pub description: Option, pub scheduled_start: DateTime, pub scheduled_end: Option>, pub settings: Option, pub registration_required: bool, pub panelists: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PanelistInvite { pub email: String, pub name: String, pub role: ParticipantRole, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpdateWebinarRequest { pub title: Option, pub description: Option, pub scheduled_start: Option>, pub scheduled_end: Option>, pub settings: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RegisterRequest { pub name: String, pub email: String, pub custom_fields: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SubmitQuestionRequest { pub question: String, pub is_anonymous: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AnswerQuestionRequest { pub answer: String, pub mark_as_live: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreatePollRequest { pub question: String, pub poll_type: PollType, pub options: Vec, pub allow_multiple_answers: Option, pub show_results_to_attendees: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VotePollRequest { pub option_ids: Vec, pub open_response: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RoleChangeRequest { pub participant_id: Uuid, pub new_role: ParticipantRole, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WebinarEvent { pub event_type: WebinarEventType, pub webinar_id: Uuid, pub data: serde_json::Value, pub timestamp: DateTime, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum WebinarEventType { WebinarStarted, WebinarEnded, WebinarPaused, WebinarResumed, ParticipantJoined, ParticipantLeft, HandRaised, HandLowered, RoleChanged, QuestionSubmitted, QuestionAnswered, PollLaunched, PollClosed, ReactionSent, PresenterChanged, ScreenShareStarted, ScreenShareEnded, // Recording events RecordingStarted, RecordingStopped, RecordingPaused, RecordingResumed, RecordingProcessed, RecordingFailed, // Transcription events TranscriptionStarted, TranscriptionCompleted, TranscriptionFailed, TranscriptionSegmentReady, } #[derive(QueryableByName)] struct WebinarRow { #[diesel(sql_type = DieselUuid)] id: Uuid, #[diesel(sql_type = DieselUuid)] organization_id: Uuid, #[diesel(sql_type = DieselUuid)] meeting_id: Uuid, #[diesel(sql_type = Text)] title: String, #[diesel(sql_type = Nullable)] description: Option, #[diesel(sql_type = Timestamptz)] scheduled_start: DateTime, #[diesel(sql_type = Nullable)] scheduled_end: Option>, #[diesel(sql_type = Nullable)] actual_start: Option>, #[diesel(sql_type = Nullable)] actual_end: Option>, #[diesel(sql_type = Text)] status: String, #[diesel(sql_type = Text)] settings_json: String, #[diesel(sql_type = Bool)] registration_required: bool, #[diesel(sql_type = Nullable)] registration_url: Option, #[diesel(sql_type = DieselUuid)] host_id: Uuid, #[diesel(sql_type = Timestamptz)] created_at: DateTime, #[diesel(sql_type = Timestamptz)] updated_at: DateTime, } #[derive(QueryableByName)] struct ParticipantRow { #[diesel(sql_type = DieselUuid)] id: Uuid, #[diesel(sql_type = DieselUuid)] webinar_id: Uuid, #[diesel(sql_type = Nullable)] user_id: Option, #[diesel(sql_type = Text)] name: String, #[diesel(sql_type = Nullable)] email: Option, #[diesel(sql_type = Text)] role: String, #[diesel(sql_type = Text)] status: String, #[diesel(sql_type = Bool)] hand_raised: bool, #[diesel(sql_type = Nullable)] hand_raised_at: Option>, #[diesel(sql_type = Bool)] is_speaking: bool, #[diesel(sql_type = Bool)] video_enabled: bool, #[diesel(sql_type = Bool)] audio_enabled: bool, #[diesel(sql_type = Bool)] screen_sharing: bool, #[diesel(sql_type = Nullable)] joined_at: Option>, #[diesel(sql_type = Nullable)] left_at: Option>, #[diesel(sql_type = Nullable)] registration_data: Option, } #[derive(QueryableByName)] struct QuestionRow { #[diesel(sql_type = DieselUuid)] id: Uuid, #[diesel(sql_type = DieselUuid)] webinar_id: Uuid, #[diesel(sql_type = Nullable)] asker_id: Option, #[diesel(sql_type = Text)] asker_name: String, #[diesel(sql_type = Bool)] is_anonymous: bool, #[diesel(sql_type = Text)] question: String, #[diesel(sql_type = Text)] status: String, #[diesel(sql_type = Integer)] upvotes: i32, #[diesel(sql_type = Nullable)] upvoted_by: Option, #[diesel(sql_type = Nullable)] answer: Option, #[diesel(sql_type = Nullable)] answered_by: Option, #[diesel(sql_type = Nullable)] answered_at: Option>, #[diesel(sql_type = Bool)] is_pinned: bool, #[diesel(sql_type = Bool)] is_highlighted: bool, #[diesel(sql_type = Timestamptz)] created_at: DateTime, } #[derive(QueryableByName)] struct CountRow { #[diesel(sql_type = BigInt)] count: i64, } pub struct WebinarService { pool: Arc>>, event_sender: broadcast::Sender, } impl WebinarService { pub fn new( pool: Arc>>, ) -> Self { let (event_sender, _) = broadcast::channel(1000); Self { pool, event_sender } } pub fn subscribe(&self) -> broadcast::Receiver { self.event_sender.subscribe() } pub async fn create_webinar( &self, organization_id: Uuid, host_id: Uuid, request: CreateWebinarRequest, ) -> Result { let mut conn = self.pool.get().map_err(|e| { error!("Failed to get database connection: {e}"); WebinarError::DatabaseConnection })?; let id = Uuid::new_v4(); let meeting_id = Uuid::new_v4(); let settings = request.settings.unwrap_or_default(); let settings_json = serde_json::to_string(&settings).unwrap_or_else(|_| "{}".to_string()); let registration_url = if request.registration_required { Some(format!("/webinar/{}/register", id)) } else { None }; let sql = r#" INSERT INTO webinars ( id, organization_id, meeting_id, title, description, scheduled_start, scheduled_end, status, settings_json, registration_required, registration_url, host_id, created_at, updated_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, 'scheduled', $8, $9, $10, $11, NOW(), NOW() ) "#; diesel::sql_query(sql) .bind::(id) .bind::(organization_id) .bind::(meeting_id) .bind::(&request.title) .bind::, _>(request.description.as_deref()) .bind::(request.scheduled_start) .bind::, _>(request.scheduled_end) .bind::(&settings_json) .bind::(request.registration_required) .bind::, _>(registration_url.as_deref()) .bind::(host_id) .execute(&mut conn) .map_err(|e| { error!("Failed to create webinar: {e}"); WebinarError::CreateFailed })?; self.add_participant_internal( &mut conn, id, Some(host_id), "Host".to_string(), None, ParticipantRole::Host, )?; if let Some(panelists) = request.panelists { for panelist in panelists { self.add_participant_internal( &mut conn, id, None, panelist.name, Some(panelist.email), panelist.role, )?; } } info!("Created webinar {} for org {}", id, organization_id); self.get_webinar(id).await } pub async fn get_webinar(&self, webinar_id: Uuid) -> Result { let mut conn = self.pool.get().map_err(|_| WebinarError::DatabaseConnection)?; let sql = r#" SELECT id, organization_id, meeting_id, title, description, scheduled_start, scheduled_end, actual_start, actual_end, status, settings_json, registration_required, registration_url, host_id, created_at, updated_at FROM webinars WHERE id = $1 "#; let rows: Vec = diesel::sql_query(sql) .bind::(webinar_id) .load(&mut conn) .map_err(|e| { error!("Failed to get webinar: {e}"); WebinarError::DatabaseConnection })?; let row = rows.into_iter().next().ok_or(WebinarError::NotFound)?; Ok(self.row_to_webinar(row)) } pub async fn start_webinar(&self, webinar_id: Uuid, host_id: Uuid) -> Result { let webinar = self.get_webinar(webinar_id).await?; if webinar.host_id != host_id { return Err(WebinarError::NotAuthorized); } if webinar.status != WebinarStatus::Scheduled && webinar.status != WebinarStatus::Paused { return Err(WebinarError::InvalidState("Webinar cannot be started".to_string())); } let mut conn = self.pool.get().map_err(|_| WebinarError::DatabaseConnection)?; diesel::sql_query( "UPDATE webinars SET status = 'live', actual_start = COALESCE(actual_start, NOW()), updated_at = NOW() WHERE id = $1" ) .bind::(webinar_id) .execute(&mut conn) .map_err(|e| { error!("Failed to start webinar: {e}"); WebinarError::UpdateFailed })?; self.broadcast_event(WebinarEventType::WebinarStarted, webinar_id, serde_json::json!({})); info!("Started webinar {}", webinar_id); self.get_webinar(webinar_id).await } pub async fn end_webinar(&self, webinar_id: Uuid, host_id: Uuid) -> Result { let webinar = self.get_webinar(webinar_id).await?; if webinar.host_id != host_id { return Err(WebinarError::NotAuthorized); } let mut conn = self.pool.get().map_err(|_| WebinarError::DatabaseConnection)?; diesel::sql_query( "UPDATE webinars SET status = 'ended', actual_end = NOW(), updated_at = NOW() WHERE id = $1" ) .bind::(webinar_id) .execute(&mut conn) .map_err(|e| { error!("Failed to end webinar: {e}"); WebinarError::UpdateFailed })?; self.broadcast_event(WebinarEventType::WebinarEnded, webinar_id, serde_json::json!({})); info!("Ended webinar {}", webinar_id); self.get_webinar(webinar_id).await } pub async fn register_attendee( &self, webinar_id: Uuid, request: RegisterRequest, ) -> Result { let webinar = self.get_webinar(webinar_id).await?; if !webinar.registration_required { return Err(WebinarError::RegistrationNotRequired); } let mut conn = self.pool.get().map_err(|_| WebinarError::DatabaseConnection)?; let existing: Vec = diesel::sql_query( "SELECT COUNT(*) as count FROM webinar_registrations WHERE webinar_id = $1 AND email = $2" ) .bind::(webinar_id) .bind::(&request.email) .load(&mut conn) .unwrap_or_default(); if existing.first().map(|r| r.count > 0).unwrap_or(false) { return Err(WebinarError::AlreadyRegistered); } let id = Uuid::new_v4(); let join_link = format!("/webinar/{}/join?token={}", webinar_id, Uuid::new_v4()); let custom_fields_json = serde_json::to_string(&request.custom_fields.unwrap_or_default()) .unwrap_or_else(|_| "{}".to_string()); let sql = r#" INSERT INTO webinar_registrations ( id, webinar_id, email, name, custom_fields, status, join_link, registered_at, confirmed_at ) VALUES ($1, $2, $3, $4, $5, 'confirmed', $6, NOW(), NOW()) "#; diesel::sql_query(sql) .bind::(id) .bind::(webinar_id) .bind::(&request.email) .bind::(&request.name) .bind::(&custom_fields_json) .bind::(&join_link) .execute(&mut conn) .map_err(|e| { error!("Failed to register: {e}"); WebinarError::RegistrationFailed })?; self.add_participant_internal( &mut conn, webinar_id, None, request.name.clone(), Some(request.email.clone()), ParticipantRole::Attendee, )?; Ok(WebinarRegistration { id, webinar_id, email: request.email, name: request.name, custom_fields: request.custom_fields.unwrap_or_default(), status: RegistrationStatus::Confirmed, join_link, registered_at: Utc::now(), confirmed_at: Some(Utc::now()), cancelled_at: None, }) } pub async fn join_webinar( &self, webinar_id: Uuid, participant_id: Uuid, ) -> Result { let webinar = self.get_webinar(webinar_id).await?; if webinar.status != WebinarStatus::Live && webinar.status != WebinarStatus::Scheduled { return Err(WebinarError::InvalidState("Webinar is not active".to_string())); } let mut conn = self.pool.get().map_err(|_| WebinarError::DatabaseConnection)?; let status = if webinar.settings.waiting_room_enabled { "in_waiting_room" } else { "joined" }; diesel::sql_query( "UPDATE webinar_participants SET status = $1, joined_at = NOW() WHERE id = $2" ) .bind::(status) .bind::(participant_id) .execute(&mut conn) .map_err(|e| { error!("Failed to join webinar: {e}"); WebinarError::JoinFailed })?; self.broadcast_event( WebinarEventType::ParticipantJoined, webinar_id, serde_json::json!({"participant_id": participant_id}), ); self.get_participant(participant_id).await } pub async fn raise_hand(&self, webinar_id: Uuid, participant_id: Uuid) -> Result<(), WebinarError> { let webinar = self.get_webinar(webinar_id).await?; if !webinar.settings.allow_hand_raise { return Err(WebinarError::FeatureDisabled("Hand raising is disabled".to_string())); } let mut conn = self.pool.get().map_err(|_| WebinarError::DatabaseConnection)?; diesel::sql_query( "UPDATE webinar_participants SET hand_raised = TRUE, hand_raised_at = NOW() WHERE id = $1 AND webinar_id = $2" ) .bind::(participant_id) .bind::(webinar_id) .execute(&mut conn) .map_err(|e| { error!("Failed to raise hand: {e}"); WebinarError::UpdateFailed })?; self.broadcast_event( WebinarEventType::HandRaised, webinar_id, serde_json::json!({"participant_id": participant_id}), ); Ok(()) } pub async fn lower_hand(&self, webinar_id: Uuid, participant_id: Uuid) -> Result<(), WebinarError> { let mut conn = self.pool.get().map_err(|_| WebinarError::DatabaseConnection)?; diesel::sql_query( "UPDATE webinar_participants SET hand_raised = FALSE, hand_raised_at = NULL WHERE id = $1 AND webinar_id = $2" ) .bind::(participant_id) .bind::(webinar_id) .execute(&mut conn) .map_err(|e| { error!("Failed to lower hand: {e}"); WebinarError::UpdateFailed })?; self.broadcast_event( WebinarEventType::HandLowered, webinar_id, serde_json::json!({"participant_id": participant_id}), ); Ok(()) } pub async fn get_raised_hands(&self, webinar_id: Uuid) -> Result, WebinarError> { let mut conn = self.pool.get().map_err(|_| WebinarError::DatabaseConnection)?; let sql = r#" SELECT id, webinar_id, user_id, name, email, role, status, hand_raised, hand_raised_at, is_speaking, video_enabled, audio_enabled, screen_sharing, joined_at, left_at, registration_data FROM webinar_participants WHERE webinar_id = $1 AND hand_raised = TRUE ORDER BY hand_raised_at ASC LIMIT $2 "#; let rows: Vec = diesel::sql_query(sql) .bind::(webinar_id) .bind::(MAX_RAISED_HANDS_VISIBLE as i32) .load(&mut conn) .unwrap_or_default(); Ok(rows.into_iter().map(|r| self.row_to_participant(r)).collect()) } pub async fn submit_question( &self, webinar_id: Uuid, asker_id: Option, asker_name: String, request: SubmitQuestionRequest, ) -> Result { let webinar = self.get_webinar(webinar_id).await?; if !webinar.settings.allow_qa { return Err(WebinarError::FeatureDisabled("Q&A is disabled".to_string())); } if request.question.len() > QA_QUESTION_MAX_LENGTH { return Err(WebinarError::InvalidInput("Question too long".to_string())); } let mut conn = self.pool.get().map_err(|_| WebinarError::DatabaseConnection)?; let id = Uuid::new_v4(); let is_anonymous = request.is_anonymous.unwrap_or(false) && webinar.settings.anonymous_qa; let status = if webinar.settings.moderated_qa { "pending" } else { "approved" }; let display_name = if is_anonymous { "Anonymous".to_string() } else { asker_name }; let sql = r#" INSERT INTO webinar_questions ( id, webinar_id, asker_id, asker_name, is_anonymous, question, status, upvotes, is_pinned, is_highlighted, created_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, 0, FALSE, FALSE, NOW()) "#; diesel::sql_query(sql) .bind::(id) .bind::(webinar_id) .bind::, _>(asker_id) .bind::(&display_name) .bind::(is_anonymous) .bind::(&request.question) .bind::(status) .execute(&mut conn) .map_err(|e| { error!("Failed to submit question: {e}"); WebinarError::CreateFailed })?; self.broadcast_event( WebinarEventType::QuestionSubmitted, webinar_id, serde_json::json!({"question_id": id}), ); Ok(QAQuestion { id, webinar_id, asker_id, asker_name: display_name, is_anonymous, question: request.question, status: if webinar.settings.moderated_qa { QuestionStatus::Pending } else { QuestionStatus::Approved }, upvotes: 0, upvoted_by: vec![], answer: None, answered_by: None, answered_at: None, is_pinned: false, is_highlighted: false, created_at: Utc::now(), }) } pub async fn answer_question( &self, question_id: Uuid, answerer_id: Uuid, request: AnswerQuestionRequest, ) -> Result { let mut conn = self.pool.get().map_err(|_| WebinarError::DatabaseConnection)?; let status = if request.mark_as_live.unwrap_or(false) { "answered_live" } else { "answered" }; diesel::sql_query( "UPDATE webinar_questions SET answer = $1, answered_by = $2, answered_at = NOW(), status = $3 WHERE id = $4" ) .bind::(&request.answer) .bind::(answerer_id) .bind::(status) .bind::(question_id) .execute(&mut conn) .map_err(|e| { error!("Failed to answer question: {e}"); WebinarError::UpdateFailed })?; self.get_question(question_id).await } pub async fn upvote_question(&self, question_id: Uuid, voter_id: Uuid) -> Result { let mut conn = self.pool.get().map_err(|_| WebinarError::DatabaseConnection)?; diesel::sql_query( "UPDATE webinar_questions SET upvotes = upvotes + 1, upvoted_by = COALESCE(upvoted_by, '[]')::jsonb || $1::jsonb WHERE id = $2" ) .bind::(serde_json::json!([voter_id]).to_string()) .bind::(question_id) .execute(&mut conn) .map_err(|e| { error!("Failed to upvote question: {e}"); WebinarError::UpdateFailed })?; self.get_question(question_id).await } pub async fn get_questions(&self, webinar_id: Uuid, include_pending: bool) -> Result, WebinarError> { let mut conn = self.pool.get().map_err(|_| WebinarError::DatabaseConnection)?; let status_filter = if include_pending { "" } else { "AND status != 'pending'" }; let sql = format!(r#" SELECT id, webinar_id, asker_id, asker_name, is_anonymous, question, status, upvotes, upvoted_by, answer, answered_by, answered_at, is_pinned, is_highlighted, created_at FROM webinar_questions WHERE webinar_id = $1 {status_filter} ORDER BY is_pinned DESC, upvotes DESC, created_at ASC "#); let rows: Vec = diesel::sql_query(&sql) .bind::(webinar_id) .load(&mut conn) .unwrap_or_default(); Ok(rows.into_iter().map(|r| self.row_to_question(r)).collect()) } async fn get_question(&self, question_id: Uuid) -> Result { let mut conn = self.pool.get().map_err(|_| WebinarError::DatabaseConnection)?; let sql = r#" SELECT id, webinar_id, asker_id, asker_name, is_anonymous, question, status, upvotes, upvoted_by, answer, answered_by, answered_at, is_pinned, is_highlighted, created_at FROM webinar_questions WHERE id = $1 "#; let rows: Vec = diesel::sql_query(sql) .bind::(question_id) .load(&mut conn) .map_err(|_| WebinarError::DatabaseConnection)?; let row = rows.into_iter().next().ok_or(WebinarError::NotFound)?; Ok(self.row_to_question(row)) } async fn get_participant(&self, participant_id: Uuid) -> Result { let mut conn = self.pool.get().map_err(|_| WebinarError::DatabaseConnection)?; let sql = r#" SELECT id, webinar_id, user_id, name, email, role, status, hand_raised, hand_raised_at, is_speaking, video_enabled, audio_enabled, screen_sharing, joined_at, left_at, registration_data FROM webinar_participants WHERE id = $1 "#; let rows: Vec = diesel::sql_query(sql) .bind::(participant_id) .load(&mut conn) .map_err(|_| WebinarError::DatabaseConnection)?; let row = rows.into_iter().next().ok_or(WebinarError::NotFound)?; Ok(self.row_to_participant(row)) } fn add_participant_internal( &self, conn: &mut diesel::PgConnection, webinar_id: Uuid, user_id: Option, name: String, email: Option, role: ParticipantRole, ) -> Result { let id = Uuid::new_v4(); diesel::sql_query(r#" INSERT INTO webinar_participants ( id, webinar_id, user_id, name, email, role, status, hand_raised, is_speaking, video_enabled, audio_enabled, screen_sharing ) VALUES ($1, $2, $3, $4, $5, $6, 'registered', FALSE, FALSE, FALSE, FALSE, FALSE) "#) .bind::(id) .bind::(webinar_id) .bind::, _>(user_id) .bind::(&name) .bind::, _>(email.as_deref()) .bind::(role.to_string()) .execute(conn) .map_err(|e| { error!("Failed to add participant: {e}"); WebinarError::CreateFailed })?; Ok(id) } fn broadcast_event(&self, event_type: WebinarEventType, webinar_id: Uuid, data: serde_json::Value) { let event = WebinarEvent { event_type, webinar_id, data, timestamp: Utc::now(), }; let _ = self.event_sender.send(event); } fn row_to_webinar(&self, row: WebinarRow) -> Webinar { let settings: WebinarSettings = serde_json::from_str(&row.settings_json).unwrap_or_default(); let status = match row.status.as_str() { "draft" => WebinarStatus::Draft, "scheduled" => WebinarStatus::Scheduled, "live" => WebinarStatus::Live, "paused" => WebinarStatus::Paused, "ended" => WebinarStatus::Ended, "cancelled" => WebinarStatus::Cancelled, _ => WebinarStatus::Draft, }; Webinar { id: row.id, organization_id: row.organization_id, meeting_id: row.meeting_id, title: row.title, description: row.description, scheduled_start: row.scheduled_start, scheduled_end: row.scheduled_end, actual_start: row.actual_start, actual_end: row.actual_end, status, settings, registration_required: row.registration_required, registration_url: row.registration_url, host_id: row.host_id, created_at: row.created_at, updated_at: row.updated_at, } } fn row_to_participant(&self, row: ParticipantRow) -> WebinarParticipant { let role = match row.role.as_str() { "host" => ParticipantRole::Host, "co_host" => ParticipantRole::CoHost, "presenter" => ParticipantRole::Presenter, "panelist" => ParticipantRole::Panelist, _ => ParticipantRole::Attendee, }; let status = match row.status.as_str() { "registered" => ParticipantStatus::Registered, "in_waiting_room" => ParticipantStatus::InWaitingRoom, "joined" => ParticipantStatus::Joined, "left" => ParticipantStatus::Left, "removed" => ParticipantStatus::Removed, _ => ParticipantStatus::Registered, }; let registration_data: Option> = row .registration_data .and_then(|d| serde_json::from_str(&d).ok()); WebinarParticipant { id: row.id, webinar_id: row.webinar_id, user_id: row.user_id, name: row.name, email: row.email, role, status, hand_raised: row.hand_raised, hand_raised_at: row.hand_raised_at, is_speaking: row.is_speaking, video_enabled: row.video_enabled, audio_enabled: row.audio_enabled, screen_sharing: row.screen_sharing, joined_at: row.joined_at, left_at: row.left_at, registration_data, } } fn row_to_question(&self, row: QuestionRow) -> QAQuestion { let status = match row.status.as_str() { "pending" => QuestionStatus::Pending, "approved" => QuestionStatus::Approved, "answered" => QuestionStatus::Answered, "dismissed" => QuestionStatus::Dismissed, "answered_live" => QuestionStatus::AnsweredLive, _ => QuestionStatus::Pending, }; let upvoted_by: Vec = row .upvoted_by .and_then(|u| serde_json::from_str(&u).ok()) .unwrap_or_default(); QAQuestion { id: row.id, webinar_id: row.webinar_id, asker_id: row.asker_id, asker_name: row.asker_name, is_anonymous: row.is_anonymous, question: row.question, status, upvotes: row.upvotes, upvoted_by, answer: row.answer, answered_by: row.answered_by, answered_at: row.answered_at, is_pinned: row.is_pinned, is_highlighted: row.is_highlighted, created_at: row.created_at, } } } #[derive(Debug, Clone)] pub enum WebinarError { DatabaseConnection, NotFound, NotAuthorized, CreateFailed, UpdateFailed, JoinFailed, InvalidState(String), InvalidInput(String), FeatureDisabled(String), RegistrationNotRequired, RegistrationFailed, AlreadyRegistered, MaxParticipantsReached, } impl std::fmt::Display for WebinarError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::DatabaseConnection => write!(f, "Database connection failed"), Self::NotFound => write!(f, "Webinar not found"), Self::NotAuthorized => write!(f, "Not authorized"), Self::CreateFailed => write!(f, "Failed to create"), Self::UpdateFailed => write!(f, "Failed to update"), Self::JoinFailed => write!(f, "Failed to join"), Self::InvalidState(msg) => write!(f, "Invalid state: {msg}"), Self::InvalidInput(msg) => write!(f, "Invalid input: {msg}"), Self::FeatureDisabled(msg) => write!(f, "Feature disabled: {msg}"), Self::RegistrationNotRequired => write!(f, "Registration not required"), Self::RegistrationFailed => write!(f, "Registration failed"), Self::AlreadyRegistered => write!(f, "Already registered"), Self::MaxParticipantsReached => write!(f, "Maximum participants reached"), } } } impl std::error::Error for WebinarError {} impl IntoResponse for WebinarError { fn into_response(self) -> axum::response::Response { let status = match self { Self::NotFound => StatusCode::NOT_FOUND, Self::NotAuthorized => StatusCode::FORBIDDEN, Self::AlreadyRegistered => StatusCode::CONFLICT, Self::InvalidInput(_) | Self::InvalidState(_) => StatusCode::BAD_REQUEST, Self::MaxParticipantsReached => StatusCode::SERVICE_UNAVAILABLE, _ => StatusCode::INTERNAL_SERVER_ERROR, }; (status, self.to_string()).into_response() } } pub fn create_webinar_tables_migration() -> &'static str { r#" CREATE TABLE IF NOT EXISTS webinars ( id UUID PRIMARY KEY, organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, meeting_id UUID NOT NULL, title TEXT NOT NULL, description TEXT, scheduled_start TIMESTAMPTZ NOT NULL, scheduled_end TIMESTAMPTZ, actual_start TIMESTAMPTZ, actual_end TIMESTAMPTZ, status TEXT NOT NULL DEFAULT 'scheduled', settings_json TEXT NOT NULL DEFAULT '{}', registration_required BOOLEAN NOT NULL DEFAULT FALSE, registration_url TEXT, host_id UUID NOT NULL REFERENCES users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS webinar_participants ( id UUID PRIMARY KEY, webinar_id UUID NOT NULL REFERENCES webinars(id) ON DELETE CASCADE, user_id UUID REFERENCES users(id), name TEXT NOT NULL, email TEXT, role TEXT NOT NULL DEFAULT 'attendee', status TEXT NOT NULL DEFAULT 'registered', hand_raised BOOLEAN NOT NULL DEFAULT FALSE, hand_raised_at TIMESTAMPTZ, is_speaking BOOLEAN NOT NULL DEFAULT FALSE, video_enabled BOOLEAN NOT NULL DEFAULT FALSE, audio_enabled BOOLEAN NOT NULL DEFAULT FALSE, screen_sharing BOOLEAN NOT NULL DEFAULT FALSE, joined_at TIMESTAMPTZ, left_at TIMESTAMPTZ, registration_data TEXT ); CREATE TABLE IF NOT EXISTS webinar_registrations ( id UUID PRIMARY KEY, webinar_id UUID NOT NULL REFERENCES webinars(id) ON DELETE CASCADE, email TEXT NOT NULL, name TEXT NOT NULL, custom_fields TEXT DEFAULT '{}', status TEXT NOT NULL DEFAULT 'pending', join_link TEXT NOT NULL, registered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), confirmed_at TIMESTAMPTZ, cancelled_at TIMESTAMPTZ, UNIQUE(webinar_id, email) ); CREATE TABLE IF NOT EXISTS webinar_questions ( id UUID PRIMARY KEY, webinar_id UUID NOT NULL REFERENCES webinars(id) ON DELETE CASCADE, asker_id UUID REFERENCES users(id), asker_name TEXT NOT NULL, is_anonymous BOOLEAN NOT NULL DEFAULT FALSE, question TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', upvotes INTEGER NOT NULL DEFAULT 0, upvoted_by TEXT, answer TEXT, answered_by UUID REFERENCES users(id), answered_at TIMESTAMPTZ, is_pinned BOOLEAN NOT NULL DEFAULT FALSE, is_highlighted BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_webinars_org ON webinars(organization_id); CREATE INDEX IF NOT EXISTS idx_webinar_participants_webinar ON webinar_participants(webinar_id); CREATE INDEX IF NOT EXISTS idx_webinar_questions_webinar ON webinar_questions(webinar_id); "# } pub fn webinar_routes(_state: Arc) -> Router> { Router::new() .route("/", post(create_webinar_handler)) .route("/:id", get(get_webinar_handler)) .route("/:id/start", post(start_webinar_handler)) .route("/:id/end", post(end_webinar_handler)) .route("/:id/register", post(register_handler)) .route("/:id/join", post(join_handler)) .route("/:id/hand/raise", post(raise_hand_handler)) .route("/:id/hand/lower", post(lower_hand_handler)) .route("/:id/hands", get(get_raised_hands_handler)) .route("/:id/questions", get(get_questions_handler)) .route("/:id/questions", post(submit_question_handler)) .route("/:id/questions/:question_id/answer", post(answer_question_handler)) .route("/:id/questions/:question_id/upvote", post(upvote_question_handler)) // Recording and transcription routes .route("/:id/recording/start", post(start_recording_handler)) .route("/:id/recording/stop", post(stop_recording_handler)) } async fn start_recording_handler( State(_state): State>, Path(_webinar_id): Path, ) -> impl IntoResponse { // Placeholder - would integrate with recording service Json(serde_json::json!({"status": "recording_started"})) } async fn stop_recording_handler( State(_state): State>, Path(_webinar_id): Path, ) -> impl IntoResponse { // Placeholder - would integrate with recording service Json(serde_json::json!({"status": "recording_stopped"})) } async fn create_webinar_handler( State(state): State>, organization_id: Uuid, host_id: Uuid, Json(request): Json, ) -> Result, WebinarError> { let service = WebinarService::new(state.conn.clone()); let webinar = service.create_webinar(organization_id, host_id, request).await?; Ok(Json(webinar)) } async fn get_webinar_handler( State(state): State>, Path(webinar_id): Path, ) -> Result, WebinarError> { let service = WebinarService::new(state.conn.clone()); let webinar = service.get_webinar(webinar_id).await?; Ok(Json(webinar)) } async fn start_webinar_handler( State(state): State>, Path(webinar_id): Path, host_id: Uuid, ) -> Result, WebinarError> { let service = WebinarService::new(state.conn.clone()); let webinar = service.start_webinar(webinar_id, host_id).await?; Ok(Json(webinar)) } async fn end_webinar_handler( State(state): State>, Path(webinar_id): Path, host_id: Uuid, ) -> Result, WebinarError> { let service = WebinarService::new(state.conn.clone()); let webinar = service.end_webinar(webinar_id, host_id).await?; Ok(Json(webinar)) } async fn register_handler( State(state): State>, Path(webinar_id): Path, Json(request): Json, ) -> Result, WebinarError> { let service = WebinarService::new(state.conn.clone()); let registration = service.register_attendee(webinar_id, request).await?; Ok(Json(registration)) } async fn join_handler( State(state): State>, Path(webinar_id): Path, participant_id: Uuid, ) -> Result, WebinarError> { let service = WebinarService::new(state.conn.clone()); let participant = service.join_webinar(webinar_id, participant_id).await?; Ok(Json(participant)) } async fn raise_hand_handler( State(state): State>, Path(webinar_id): Path, participant_id: Uuid, ) -> Result { let service = WebinarService::new(state.conn.clone()); service.raise_hand(webinar_id, participant_id).await?; Ok(StatusCode::OK) } async fn lower_hand_handler( State(state): State>, Path(webinar_id): Path, participant_id: Uuid, ) -> Result { let service = WebinarService::new(state.conn.clone()); service.lower_hand(webinar_id, participant_id).await?; Ok(StatusCode::OK) } async fn get_raised_hands_handler( State(state): State>, Path(webinar_id): Path, ) -> Result>, WebinarError> { let service = WebinarService::new(state.conn.clone()); let hands = service.get_raised_hands(webinar_id).await?; Ok(Json(hands)) } async fn get_questions_handler( State(state): State>, Path(webinar_id): Path, ) -> Result>, WebinarError> { let service = WebinarService::new(state.conn.clone()); let questions = service.get_questions(webinar_id, false).await?; Ok(Json(questions)) } async fn submit_question_handler( State(state): State>, Path(webinar_id): Path, asker_id: Option, Json(request): Json, ) -> Result, WebinarError> { let service = WebinarService::new(state.conn.clone()); let question = service.submit_question(webinar_id, asker_id, "Anonymous".to_string(), request).await?; Ok(Json(question)) } async fn answer_question_handler( State(state): State>, Path((webinar_id, question_id)): Path<(Uuid, Uuid)>, answerer_id: Uuid, Json(request): Json, ) -> Result, WebinarError> { let service = WebinarService::new(state.conn.clone()); let question = service.answer_question(question_id, answerer_id, request).await?; Ok(Json(question)) } async fn upvote_question_handler( State(state): State>, Path((webinar_id, question_id)): Path<(Uuid, Uuid)>, voter_id: Uuid, ) -> Result, WebinarError> { let service = WebinarService::new(state.conn.clone()); let question = service.upvote_question(question_id, voter_id).await?; Ok(Json(question)) } #[cfg(test)] mod tests { use super::*; #[test] fn test_webinar_status_display() { assert_eq!(WebinarStatus::Draft.to_string(), "draft"); assert_eq!(WebinarStatus::Live.to_string(), "live"); assert_eq!(WebinarStatus::Ended.to_string(), "ended"); } #[test] fn test_participant_role_can_present() { assert!(ParticipantRole::Host.can_present()); assert!(ParticipantRole::Presenter.can_present()); assert!(!ParticipantRole::Attendee.can_present()); } }