From 671f6280eb72da452f607c5d299cc8a0378a85d8 Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Sat, 20 Dec 2025 16:57:44 -0600 Subject: [PATCH] Feat: Add meeting recording controller and webhook handler (Phase 4) Add start/stop recording endpoints that integrate with Recall.ai bot for meeting capture. Add webhook handler for receiving recording status updates from Recall.ai. --- domain/src/lib.rs | 6 +- .../meeting_recording_controller.rs | 350 ++++++++++++++++++ web/src/controller/mod.rs | 2 + web/src/controller/webhook_controller.rs | 196 ++++++++++ web/src/router.rs | 39 +- 5 files changed, 587 insertions(+), 6 deletions(-) create mode 100644 web/src/controller/meeting_recording_controller.rs create mode 100644 web/src/controller/webhook_controller.rs diff --git a/domain/src/lib.rs b/domain/src/lib.rs index a6619281..39a6dbc5 100644 --- a/domain/src/lib.rs +++ b/domain/src/lib.rs @@ -17,9 +17,9 @@ pub use entity_api::{ // AI Meeting Integration re-exports pub use entity_api::{ - ai_privacy_level, ai_suggested_items, ai_suggestion, meeting_recording_status, - meeting_recordings, sentiment, transcript_segments, transcription_status, transcriptions, - user_integration, user_integrations, + ai_privacy_level, ai_suggested_items, ai_suggestion, meeting_recording, + meeting_recording_status, meeting_recordings, sentiment, transcript_segments, + transcription_status, transcriptions, user_integration, user_integrations, }; pub mod action; diff --git a/web/src/controller/meeting_recording_controller.rs b/web/src/controller/meeting_recording_controller.rs new file mode 100644 index 00000000..a1a37325 --- /dev/null +++ b/web/src/controller/meeting_recording_controller.rs @@ -0,0 +1,350 @@ +//! Controller for meeting recording operations. +//! +//! Handles starting, stopping, and querying meeting recordings via Recall.ai. + +use crate::controller::ApiResponse; +use crate::extractors::authenticated_user::AuthenticatedUser; +use crate::extractors::compare_api_version::CompareApiVersion; +use crate::{AppState, Error}; + +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::Json; + +use domain::ai_privacy_level::AiPrivacyLevel; +use domain::coaching_relationship as CoachingRelationshipApi; +use domain::coaching_session as CoachingSessionApi; +use domain::gateway::recall_ai::{create_standard_bot_request, RecallAiClient, RecallRegion}; +use domain::meeting_recording as MeetingRecordingApi; +use domain::meeting_recording_status::MeetingRecordingStatus; +use domain::meeting_recordings::Model as MeetingRecordingModel; +use domain::{user_integration, Id}; +use log::*; +use service::config::ApiVersion; + +/// Helper to create a forbidden error +fn forbidden_error(message: &str) -> Error { + Error::Domain(domain::error::Error { + source: None, + error_kind: domain::error::DomainErrorKind::Internal( + domain::error::InternalErrorKind::Entity(domain::error::EntityErrorKind::Other( + message.to_string(), + )), + ), + }) +} + +/// Helper to create a bad request error +fn bad_request_error(message: &str) -> Error { + Error::Domain(domain::error::Error { + source: None, + error_kind: domain::error::DomainErrorKind::Internal( + domain::error::InternalErrorKind::Entity(domain::error::EntityErrorKind::Other( + message.to_string(), + )), + ), + }) +} + +/// Helper to create an internal error +fn internal_error(message: &str) -> Error { + Error::Domain(domain::error::Error { + source: None, + error_kind: domain::error::DomainErrorKind::Internal( + domain::error::InternalErrorKind::Other(message.to_string()), + ), + }) +} + +/// GET /coaching_sessions/{id}/recording +/// +/// Get the current recording status for a coaching session. +#[utoipa::path( + get, + path = "/coaching_sessions/{id}/recording", + params( + ApiVersion, + ("id" = Id, Path, description = "Coaching session ID"), + ), + responses( + (status = 200, description = "Recording status retrieved", body = meeting_recordings::Model), + (status = 401, description = "Unauthorized"), + (status = 404, description = "No recording found for this session"), + ), + security( + ("cookie_auth" = []) + ) +)] +pub async fn get_recording_status( + CompareApiVersion(_v): CompareApiVersion, + AuthenticatedUser(_user): AuthenticatedUser, + State(app_state): State, + Path(session_id): Path, +) -> Result { + debug!("GET recording status for session: {session_id}"); + + let recording: Option = + MeetingRecordingApi::find_latest_by_coaching_session_id( + app_state.db_conn_ref(), + session_id, + ) + .await?; + + match recording { + Some(rec) => Ok(Json(ApiResponse::new(StatusCode::OK.into(), rec))), + None => Err(Error::Domain(domain::error::Error { + source: None, + error_kind: domain::error::DomainErrorKind::Internal( + domain::error::InternalErrorKind::Entity(domain::error::EntityErrorKind::NotFound), + ), + })), + } +} + +/// POST /coaching_sessions/{id}/recording/start +/// +/// Start recording a coaching session via Recall.ai bot. +/// Only the coach can start recording, and AI features must be enabled for the relationship. +#[utoipa::path( + post, + path = "/coaching_sessions/{id}/recording/start", + params( + ApiVersion, + ("id" = Id, Path, description = "Coaching session ID"), + ), + responses( + (status = 201, description = "Recording started successfully", body = meeting_recordings::Model), + (status = 400, description = "Cannot start recording (AI disabled or no meeting URL)"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Only the coach can start recording"), + ), + security( + ("cookie_auth" = []) + ) +)] +pub async fn start_recording( + CompareApiVersion(_v): CompareApiVersion, + AuthenticatedUser(user): AuthenticatedUser, + State(app_state): State, + Path(session_id): Path, +) -> Result { + info!("Starting recording for session: {session_id}"); + + let db = app_state.db_conn_ref(); + let config = &app_state.config; + + // 1. Get the coaching session + let session = CoachingSessionApi::find_by_id(db, session_id).await?; + + // 2. Get the coaching relationship + let relationship = + CoachingRelationshipApi::find_by_id(db, session.coaching_relationship_id).await?; + + // 3. Verify user is the coach + if relationship.coach_id != user.id { + warn!( + "User {} attempted to start recording for session {} but is not the coach", + user.id, session_id + ); + return Err(forbidden_error("Only the coach can start recording")); + } + + // 4. Check AI privacy level + if relationship.ai_privacy_level == AiPrivacyLevel::None { + return Err(bad_request_error( + "AI recording is disabled for this coaching relationship", + )); + } + + // 5. Check for meeting URL + let meeting_url = relationship.meeting_url.clone().ok_or_else(|| { + bad_request_error("No meeting URL configured for this coaching relationship") + })?; + + // 6. Get user's Recall.ai API key + let user_integrations = user_integration::find_by_user_id(db, user.id) + .await? + .ok_or_else(|| { + bad_request_error("No integrations configured. Please set up Recall.ai in Settings.") + })?; + + let api_key = user_integrations.recall_ai_api_key.clone().ok_or_else(|| { + bad_request_error("Recall.ai API key not configured. Please set up in Settings.") + })?; + + // 7. Determine region + let region_str = user_integrations + .recall_ai_region + .as_deref() + .unwrap_or("us-west-2"); + let region: RecallRegion = region_str.parse().unwrap_or(RecallRegion::UsWest2); + + // 8. Create meeting recording record + let mut recording: MeetingRecordingModel = MeetingRecordingApi::create(db, session_id).await?; + + // 9. Create Recall.ai bot + let client = RecallAiClient::new(&api_key, region, config.recall_ai_base_domain())?; + + // Build webhook URL for status updates + let webhook_url = config + .webhook_base_url() + .map(|base| format!("{}/webhooks/recall", base)); + + let bot_request = create_standard_bot_request( + meeting_url, + "Refactor Coaching Notetaker".to_string(), + webhook_url, + ); + + match client.create_bot(bot_request).await { + Ok(response) => { + info!( + "Recall.ai bot created: {} for session {}", + response.id, session_id + ); + + // Update recording with bot ID and status + recording.recall_bot_id = Some(response.id); + recording.status = MeetingRecordingStatus::Joining; + recording.started_at = Some(chrono::Utc::now().into()); + + let updated: MeetingRecordingModel = + MeetingRecordingApi::update(db, recording.id, recording).await?; + + Ok(( + StatusCode::CREATED, + Json(ApiResponse::new(StatusCode::CREATED.into(), updated)), + )) + } + Err(e) => { + warn!( + "Failed to create Recall.ai bot for session {}: {:?}", + session_id, e + ); + + // Update recording with error status + recording.status = MeetingRecordingStatus::Failed; + recording.error_message = Some(format!("Failed to create bot: {:?}", e)); + let _ = MeetingRecordingApi::update(db, recording.id, recording).await; + + Err(internal_error("Failed to start recording")) + } + } +} + +/// POST /coaching_sessions/{id}/recording/stop +/// +/// Stop an active recording for a coaching session. +/// Only the coach can stop recording. +#[utoipa::path( + post, + path = "/coaching_sessions/{id}/recording/stop", + params( + ApiVersion, + ("id" = Id, Path, description = "Coaching session ID"), + ), + responses( + (status = 200, description = "Recording stopped successfully", body = meeting_recordings::Model), + (status = 400, description = "No active recording to stop"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Only the coach can stop recording"), + ), + security( + ("cookie_auth" = []) + ) +)] +pub async fn stop_recording( + CompareApiVersion(_v): CompareApiVersion, + AuthenticatedUser(user): AuthenticatedUser, + State(app_state): State, + Path(session_id): Path, +) -> Result { + info!("Stopping recording for session: {session_id}"); + + let db = app_state.db_conn_ref(); + let config = &app_state.config; + + // 1. Get the coaching session + let session = CoachingSessionApi::find_by_id(db, session_id).await?; + + // 2. Get the coaching relationship + let relationship = + CoachingRelationshipApi::find_by_id(db, session.coaching_relationship_id).await?; + + // 3. Verify user is the coach + if relationship.coach_id != user.id { + warn!( + "User {} attempted to stop recording for session {} but is not the coach", + user.id, session_id + ); + return Err(forbidden_error("Only the coach can stop recording")); + } + + // 4. Get the active recording + let recording: MeetingRecordingModel = + MeetingRecordingApi::find_latest_by_coaching_session_id(db, session_id) + .await? + .ok_or_else(|| bad_request_error("No recording found for this session"))?; + + // 5. Check if recording is active + if !matches!( + recording.status, + MeetingRecordingStatus::Joining | MeetingRecordingStatus::Recording + ) { + return Err(bad_request_error("No active recording to stop")); + } + + // 6. Get bot ID + let bot_id = recording + .recall_bot_id + .clone() + .ok_or_else(|| internal_error("Recording has no associated bot ID"))?; + + // 7. Get user's Recall.ai API key + let user_integrations = user_integration::find_by_user_id(db, user.id) + .await? + .ok_or_else(|| internal_error("User integrations not found"))?; + + let api_key = user_integrations + .recall_ai_api_key + .clone() + .ok_or_else(|| internal_error("Recall.ai API key not found"))?; + + // 8. Determine region + let region_str = user_integrations + .recall_ai_region + .as_deref() + .unwrap_or("us-west-2"); + let region: RecallRegion = region_str.parse().unwrap_or(RecallRegion::UsWest2); + + // 9. Stop the Recall.ai bot + let client = RecallAiClient::new(&api_key, region, config.recall_ai_base_domain())?; + + match client.stop_bot(&bot_id).await { + Ok(_) => { + info!( + "Recall.ai bot stopped: {} for session {}", + bot_id, session_id + ); + + // Update recording status + let updated: MeetingRecordingModel = MeetingRecordingApi::update_status( + db, + recording.id, + MeetingRecordingStatus::Processing, + None, + ) + .await?; + + Ok(Json(ApiResponse::new(StatusCode::OK.into(), updated))) + } + Err(e) => { + warn!( + "Failed to stop Recall.ai bot {} for session {}: {:?}", + bot_id, session_id, e + ); + Err(internal_error("Failed to stop recording")) + } + } +} diff --git a/web/src/controller/mod.rs b/web/src/controller/mod.rs index d2f84b97..9f833d95 100644 --- a/web/src/controller/mod.rs +++ b/web/src/controller/mod.rs @@ -6,6 +6,7 @@ pub(crate) mod coaching_session_controller; pub(crate) mod health_check_controller; pub(crate) mod integration_controller; pub(crate) mod jwt_controller; +pub(crate) mod meeting_recording_controller; pub(crate) mod note_controller; pub(crate) mod oauth_controller; pub(crate) mod organization; @@ -14,6 +15,7 @@ pub(crate) mod overarching_goal_controller; pub(crate) mod user; pub(crate) mod user_controller; pub(crate) mod user_session_controller; +pub(crate) mod webhook_controller; #[derive(Debug, Serialize)] struct ApiResponse { diff --git a/web/src/controller/webhook_controller.rs b/web/src/controller/webhook_controller.rs new file mode 100644 index 00000000..193c191a --- /dev/null +++ b/web/src/controller/webhook_controller.rs @@ -0,0 +1,196 @@ +//! Controller for handling webhooks from external services. +//! +//! Handles webhooks from Recall.ai for meeting recording status updates. + +use crate::{AppState, Error}; + +use axum::extract::State; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::IntoResponse; +use axum::Json; + +use domain::meeting_recording as MeetingRecordingApi; +use domain::meeting_recording_status::MeetingRecordingStatus; +use domain::meeting_recordings::Model as MeetingRecordingModel; +use log::*; +use serde::{Deserialize, Serialize}; + +/// Recall.ai webhook event payload +#[derive(Debug, Deserialize)] +pub struct RecallWebhookPayload { + /// The type of event + pub event: String, + /// The bot ID this event is for + pub data: RecallWebhookData, +} + +/// Data section of Recall.ai webhook +#[derive(Debug, Deserialize)] +pub struct RecallWebhookData { + /// Bot ID + pub bot_id: String, + /// Status code (for status change events) + pub status: Option, + /// Video URL (available when recording is complete) + pub video_url: Option, + /// Recording duration in seconds + pub duration: Option, + /// Error details if the bot failed + pub error: Option, +} + +/// Recall.ai bot status +#[derive(Debug, Deserialize)] +pub struct RecallBotStatus { + pub code: String, +} + +/// Recall.ai error details +#[derive(Debug, Deserialize)] +pub struct RecallError { + pub code: Option, + pub message: Option, +} + +/// Response for webhook acknowledgment +#[derive(Debug, Serialize)] +pub struct WebhookResponse { + pub status: String, +} + +/// Maps Recall.ai status codes to our internal status +fn map_recall_status(code: &str) -> MeetingRecordingStatus { + match code { + "ready" | "joining_call" => MeetingRecordingStatus::Joining, + "in_call_not_recording" | "in_waiting_room" => MeetingRecordingStatus::Joining, + "in_call_recording" => MeetingRecordingStatus::Recording, + "call_ended" | "done" => MeetingRecordingStatus::Processing, + "analysis_done" => MeetingRecordingStatus::Completed, + "fatal" | "error" => MeetingRecordingStatus::Failed, + _ => MeetingRecordingStatus::Pending, + } +} + +/// POST /webhooks/recall +/// +/// Handles webhook callbacks from Recall.ai for bot status updates. +/// This endpoint does not require authentication but validates via webhook secret. +pub async fn recall_webhook( + State(app_state): State, + headers: HeaderMap, + Json(payload): Json, +) -> Result { + debug!("Received Recall.ai webhook: {:?}", payload.event); + + let config = &app_state.config; + let db = app_state.db_conn_ref(); + + // Validate webhook secret if configured + if let Some(expected_secret) = config.webhook_secret() { + let provided_secret = headers + .get("x-webhook-secret") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + if provided_secret != expected_secret { + warn!("Invalid webhook secret received"); + return Ok(( + StatusCode::UNAUTHORIZED, + Json(WebhookResponse { + status: "unauthorized".to_string(), + }), + )); + } + } + + let bot_id = &payload.data.bot_id; + + // Find the recording by bot ID + let recording: Option = + MeetingRecordingApi::find_by_recall_bot_id(db, bot_id).await?; + + let recording = match recording { + Some(r) => r, + None => { + warn!("Received webhook for unknown bot ID: {}", bot_id); + return Ok(( + StatusCode::OK, + Json(WebhookResponse { + status: "ignored".to_string(), + }), + )); + } + }; + + // Handle different event types + match payload.event.as_str() { + "bot.status_change" => { + if let Some(status) = &payload.data.status { + let new_status = map_recall_status(&status.code); + info!( + "Bot {} status changed to {} (internal: {:?})", + bot_id, status.code, new_status + ); + + // Check for errors + let error_message = if new_status == MeetingRecordingStatus::Failed { + payload.data.error.as_ref().map(|e| { + format!( + "{}: {}", + e.code.as_deref().unwrap_or("unknown"), + e.message.as_deref().unwrap_or("Unknown error") + ) + }) + } else { + None + }; + + let _: MeetingRecordingModel = + MeetingRecordingApi::update_status(db, recording.id, new_status, error_message) + .await?; + } + } + "bot.done" | "recording.done" => { + info!("Bot {} recording completed", bot_id); + + // Update with video URL and duration if available + let mut updated_recording = recording.clone(); + updated_recording.status = MeetingRecordingStatus::Completed; + updated_recording.recording_url = payload.data.video_url.clone(); + updated_recording.duration_seconds = payload.data.duration; + updated_recording.ended_at = Some(chrono::Utc::now().into()); + + let _: MeetingRecordingModel = + MeetingRecordingApi::update(db, recording.id, updated_recording).await?; + } + "bot.error" | "recording.error" => { + warn!("Bot {} encountered an error", bot_id); + + let error_message = payload.data.error.as_ref().map(|e| { + format!( + "{}: {}", + e.code.as_deref().unwrap_or("unknown"), + e.message.as_deref().unwrap_or("Unknown error") + ) + }); + + let _: MeetingRecordingModel = MeetingRecordingApi::update_status( + db, + recording.id, + MeetingRecordingStatus::Failed, + error_message, + ) + .await?; + } + _ => { + debug!("Ignoring unhandled Recall.ai event: {}", payload.event); + } + } + + Ok(( + StatusCode::OK, + Json(WebhookResponse { + status: "ok".to_string(), + }), + )) +} diff --git a/web/src/router.rs b/web/src/router.rs index c293f30d..bc4519d4 100644 --- a/web/src/router.rs +++ b/web/src/router.rs @@ -10,9 +10,10 @@ use tower_http::services::ServeDir; use crate::controller::{ action_controller, agreement_controller, coaching_relationship_controller, - coaching_session_controller, integration_controller, jwt_controller, note_controller, - oauth_controller, organization, organization_controller, overarching_goal_controller, user, - user_controller, user_session_controller, + coaching_session_controller, integration_controller, jwt_controller, + meeting_recording_controller, note_controller, oauth_controller, organization, + organization_controller, overarching_goal_controller, user, user_controller, + user_session_controller, webhook_controller, }; use utoipa::{ @@ -74,6 +75,9 @@ use utoipa_rapidoc::RapiDoc; user::coaching_session_controller::index, user::overarching_goal_controller::index, jwt_controller::generate_collab_token, + meeting_recording_controller::get_recording_status, + meeting_recording_controller::start_recording, + meeting_recording_controller::stop_recording, ), components( schemas( @@ -81,6 +85,7 @@ use utoipa_rapidoc::RapiDoc; domain::agreements::Model, domain::coaching_sessions::Model, domain::coaching_relationships::Model, + domain::meeting_recordings::Model, domain::notes::Model, domain::organizations::Model, domain::overarching_goals::Model, @@ -137,6 +142,8 @@ pub fn define_routes(app_state: AppState) -> Router { .merge(user_session_routes()) .merge(user_session_protected_routes(app_state.clone())) .merge(coaching_sessions_routes(app_state.clone())) + .merge(meeting_recording_routes(app_state.clone())) + .merge(webhook_routes(app_state.clone())) .merge(jwt_routes(app_state.clone())) // **** FIXME: protect the OpenAPI web UI .merge(RapiDoc::with_openapi("/api-docs/openapi2.json", ApiDoc::openapi()).path("/rapidoc")) @@ -560,6 +567,32 @@ fn oauth_routes(app_state: AppState) -> Router { .with_state(app_state) } +/// Routes for meeting recording operations +fn meeting_recording_routes(app_state: AppState) -> Router { + Router::new() + .route( + "/coaching_sessions/:id/recording", + get(meeting_recording_controller::get_recording_status), + ) + .route( + "/coaching_sessions/:id/recording/start", + post(meeting_recording_controller::start_recording), + ) + .route( + "/coaching_sessions/:id/recording/stop", + post(meeting_recording_controller::stop_recording), + ) + .route_layer(from_fn(require_auth)) + .with_state(app_state) +} + +/// Routes for external service webhooks (no authentication - validated by webhook secret) +fn webhook_routes(app_state: AppState) -> Router { + Router::new() + .route("/webhooks/recall", post(webhook_controller::recall_webhook)) + .with_state(app_state) +} + // This will serve static files that we can use as a "fallback" for when the server panics pub fn static_routes() -> Router { Router::new().nest_service("/", ServeDir::new("./"))