diff --git a/Cargo.lock b/Cargo.lock index 0a4e8eb5..12861eb1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,41 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.7.8" @@ -595,6 +630,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.5.39" @@ -756,9 +801,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "darling" version = "0.20.11" @@ -850,19 +905,25 @@ dependencies = [ name = "domain" version = "1.0.0-beta2" dependencies = [ + "aes-gcm", + "base64", "chrono", "email_address", "entity_api", + "hex", "jsonwebtoken", "log", "mockito", + "rand 0.8.5", "reqwest", "sea-orm", "serde", "serde_json", "serial_test", "service", + "thiserror 2.0.12", "tokio", + "urlencoding", ] [[package]] @@ -1194,6 +1255,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gimli" version = "0.31.1" @@ -1608,6 +1679,15 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -1961,6 +2041,12 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl" version = "0.10.73" @@ -2211,6 +2297,18 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.2" @@ -4031,6 +4129,16 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "402bb19d8e03f1d1a7450e2bd613980869438e0666331be3e073089124aa1adc" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" diff --git a/domain/Cargo.toml b/domain/Cargo.toml index fe98f67d..c3af2051 100644 --- a/domain/Cargo.toml +++ b/domain/Cargo.toml @@ -4,16 +4,22 @@ version = "1.0.0-beta2" edition = "2021" [dependencies] +aes-gcm = "0.10" +base64 = "0.22" chrono = { version = "0.4.38", features = ["serde"] } email_address = "0.2" entity_api = { path = "../entity_api" } +hex = "0.4" jsonwebtoken = "9" service = { path = "../service" } log = "0.4.22" +rand = "0.8" reqwest = { version = "0.12.12", features = ["json", "rustls-tls"] } serde_json = "1.0.128" serde = {version = "1.0.210", features = ["derive"] } +thiserror = "2.0" tokio = { version = "1.0", features = ["full"] } +urlencoding = "2.1" [dependencies.sea-orm] version = "1.1.0" # sea-orm version diff --git a/domain/src/coaching_relationship.rs b/domain/src/coaching_relationship.rs index 08d8b214..840e7aee 100644 --- a/domain/src/coaching_relationship.rs +++ b/domain/src/coaching_relationship.rs @@ -6,7 +6,7 @@ use sea_orm::{DatabaseConnection, TransactionTrait}; pub use entity_api::coaching_relationship::{ create, find_by_id, find_by_organization_with_user_names, find_by_user, - find_by_user_and_organization_with_user_names, get_relationship_with_user_names, + find_by_user_and_organization_with_user_names, get_relationship_with_user_names, update, CoachingRelationshipWithUserNames, }; diff --git a/domain/src/encryption.rs b/domain/src/encryption.rs new file mode 100644 index 00000000..3833e880 --- /dev/null +++ b/domain/src/encryption.rs @@ -0,0 +1,245 @@ +//! AES-256-GCM encryption utilities for securing API keys stored in the database. +//! +//! This module provides functions to encrypt and decrypt sensitive data like API keys +//! before storing them in the database. The encryption key should be a 32-byte key +//! provided via the ENCRYPTION_KEY environment variable (hex-encoded). + +use aes_gcm::{ + aead::{Aead, KeyInit}, + Aes256Gcm, Nonce, +}; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use rand::Rng; +use thiserror::Error; + +/// 12-byte nonce size for AES-GCM +const NONCE_SIZE: usize = 12; + +/// Errors that can occur during encryption/decryption operations +#[derive(Debug, Error)] +pub enum EncryptionError { + #[error("Invalid encryption key: must be 32 bytes (64 hex characters)")] + InvalidKey, + + #[error("Failed to decode hex key: {0}")] + HexDecodeError(#[from] hex::FromHexError), + + #[error("Failed to decode base64 ciphertext: {0}")] + Base64DecodeError(#[from] base64::DecodeError), + + #[error("Encryption failed")] + EncryptionFailed, + + #[error("Decryption failed - data may be corrupted or key is incorrect")] + DecryptionFailed, + + #[error("Ciphertext too short - missing nonce")] + CiphertextTooShort, + + #[error("No encryption key configured")] + NoKeyConfigured, +} + +/// Encrypts plaintext using AES-256-GCM with a random nonce. +/// +/// The nonce is prepended to the ciphertext, and the result is base64-encoded +/// for safe storage in a text database column. +/// +/// # Arguments +/// * `plaintext` - The data to encrypt +/// * `key_hex` - The 32-byte encryption key as a hex string (64 characters) +/// +/// # Returns +/// Base64-encoded string containing nonce + ciphertext +pub fn encrypt(plaintext: &str, key_hex: &str) -> Result { + let key = parse_key(key_hex)?; + let cipher = Aes256Gcm::new_from_slice(&key).map_err(|_| EncryptionError::InvalidKey)?; + + // Generate a random 12-byte nonce + let mut nonce_bytes = [0u8; NONCE_SIZE]; + rand::thread_rng().fill(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + + // Encrypt the plaintext + let ciphertext = cipher + .encrypt(nonce, plaintext.as_bytes()) + .map_err(|_| EncryptionError::EncryptionFailed)?; + + // Prepend nonce to ciphertext and base64 encode + let mut combined = nonce_bytes.to_vec(); + combined.extend(ciphertext); + + Ok(BASE64.encode(combined)) +} + +/// Decrypts a base64-encoded ciphertext that was encrypted with `encrypt()`. +/// +/// # Arguments +/// * `ciphertext_b64` - Base64-encoded string containing nonce + ciphertext +/// * `key_hex` - The 32-byte encryption key as a hex string (64 characters) +/// +/// # Returns +/// The original plaintext string +pub fn decrypt(ciphertext_b64: &str, key_hex: &str) -> Result { + let key = parse_key(key_hex)?; + let cipher = Aes256Gcm::new_from_slice(&key).map_err(|_| EncryptionError::InvalidKey)?; + + // Decode base64 + let combined = BASE64.decode(ciphertext_b64)?; + + // Split nonce and ciphertext + if combined.len() < NONCE_SIZE { + return Err(EncryptionError::CiphertextTooShort); + } + + let (nonce_bytes, ciphertext) = combined.split_at(NONCE_SIZE); + let nonce = Nonce::from_slice(nonce_bytes); + + // Decrypt + let plaintext_bytes = cipher + .decrypt(nonce, ciphertext) + .map_err(|_| EncryptionError::DecryptionFailed)?; + + String::from_utf8(plaintext_bytes).map_err(|_| EncryptionError::DecryptionFailed) +} + +/// Encrypts a value if an encryption key is available, otherwise returns None. +/// +/// This is useful for optional encryption when the key might not be configured. +pub fn encrypt_optional( + plaintext: Option<&str>, + key_hex: Option<&str>, +) -> Result, EncryptionError> { + match (plaintext, key_hex) { + (Some(pt), Some(key)) => Ok(Some(encrypt(pt, key)?)), + (Some(_), None) => Err(EncryptionError::NoKeyConfigured), + (None, _) => Ok(None), + } +} + +/// Decrypts a value if an encryption key is available, otherwise returns None. +pub fn decrypt_optional( + ciphertext: Option<&str>, + key_hex: Option<&str>, +) -> Result, EncryptionError> { + match (ciphertext, key_hex) { + (Some(ct), Some(key)) => Ok(Some(decrypt(ct, key)?)), + (Some(_), None) => Err(EncryptionError::NoKeyConfigured), + (None, _) => Ok(None), + } +} + +/// Parses a hex-encoded 32-byte key +fn parse_key(key_hex: &str) -> Result<[u8; 32], EncryptionError> { + let bytes = hex::decode(key_hex)?; + if bytes.len() != 32 { + return Err(EncryptionError::InvalidKey); + } + let mut key = [0u8; 32]; + key.copy_from_slice(&bytes); + Ok(key) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Test key: 32 bytes = 64 hex characters + const TEST_KEY: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + #[test] + fn test_encrypt_decrypt_roundtrip() { + let plaintext = "my-secret-api-key-12345"; + let encrypted = encrypt(plaintext, TEST_KEY).expect("encryption should succeed"); + + // Encrypted should be different from plaintext + assert_ne!(encrypted, plaintext); + + // Should be able to decrypt back to original + let decrypted = decrypt(&encrypted, TEST_KEY).expect("decryption should succeed"); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn test_encrypt_produces_different_outputs() { + // Due to random nonce, encrypting same plaintext should produce different ciphertexts + let plaintext = "test-api-key"; + let encrypted1 = encrypt(plaintext, TEST_KEY).unwrap(); + let encrypted2 = encrypt(plaintext, TEST_KEY).unwrap(); + + assert_ne!(encrypted1, encrypted2); + + // But both should decrypt to the same value + assert_eq!(decrypt(&encrypted1, TEST_KEY).unwrap(), plaintext); + assert_eq!(decrypt(&encrypted2, TEST_KEY).unwrap(), plaintext); + } + + #[test] + fn test_invalid_key_length() { + let result = encrypt("test", "short_key"); + assert!(matches!( + result, + Err(EncryptionError::HexDecodeError(_)) | Err(EncryptionError::InvalidKey) + )); + } + + #[test] + fn test_wrong_key_fails_decryption() { + let plaintext = "secret"; + let encrypted = encrypt(plaintext, TEST_KEY).unwrap(); + + let wrong_key = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; + let result = decrypt(&encrypted, wrong_key); + + assert!(matches!(result, Err(EncryptionError::DecryptionFailed))); + } + + #[test] + fn test_corrupted_ciphertext_fails() { + let result = decrypt("not_valid_base64!!!", TEST_KEY); + assert!(matches!(result, Err(EncryptionError::Base64DecodeError(_)))); + } + + #[test] + fn test_ciphertext_too_short() { + // Valid base64 but too short to contain nonce + let result = decrypt("YWJj", TEST_KEY); // "abc" in base64 + assert!(matches!(result, Err(EncryptionError::CiphertextTooShort))); + } + + #[test] + fn test_encrypt_optional_with_key() { + let result = encrypt_optional(Some("test"), Some(TEST_KEY)); + assert!(result.is_ok()); + assert!(result.unwrap().is_some()); + } + + #[test] + fn test_encrypt_optional_without_key() { + let result = encrypt_optional(Some("test"), None); + assert!(matches!(result, Err(EncryptionError::NoKeyConfigured))); + } + + #[test] + fn test_encrypt_optional_without_value() { + let result = encrypt_optional(None, Some(TEST_KEY)); + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); + } + + #[test] + fn test_unicode_plaintext() { + let plaintext = "API密钥🔐with-unicode-✓"; + let encrypted = encrypt(plaintext, TEST_KEY).unwrap(); + let decrypted = decrypt(&encrypted, TEST_KEY).unwrap(); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn test_empty_plaintext() { + let plaintext = ""; + let encrypted = encrypt(plaintext, TEST_KEY).unwrap(); + let decrypted = decrypt(&encrypted, TEST_KEY).unwrap(); + assert_eq!(decrypted, plaintext); + } +} diff --git a/domain/src/gateway/assembly_ai.rs b/domain/src/gateway/assembly_ai.rs new file mode 100644 index 00000000..9dd92577 --- /dev/null +++ b/domain/src/gateway/assembly_ai.rs @@ -0,0 +1,321 @@ +//! AssemblyAI API client for transcription services. +//! +//! This module provides an HTTP client for interacting with the AssemblyAI API +//! to transcribe meeting recordings with speaker diarization and AI features. + +use crate::error::{DomainErrorKind, Error, ExternalErrorKind, InternalErrorKind}; +use log::*; +use serde::{Deserialize, Serialize}; + +/// Request to create a new transcription +#[derive(Debug, Serialize)] +pub struct CreateTranscriptRequest { + pub audio_url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub webhook_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub webhook_auth_header_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub webhook_auth_header_value: Option, + pub speaker_labels: bool, + pub sentiment_analysis: bool, + pub auto_chapters: bool, + pub entity_detection: bool, +} + +/// Response from creating a transcript +#[derive(Debug, Deserialize)] +pub struct TranscriptResponse { + pub id: String, + pub status: TranscriptStatus, + #[serde(default)] + pub text: Option, + #[serde(default)] + pub words: Option>, + #[serde(default)] + pub utterances: Option>, + #[serde(default)] + pub chapters: Option>, + #[serde(default)] + pub sentiment_analysis_results: Option>, + #[serde(default)] + pub confidence: Option, + #[serde(default)] + pub audio_duration: Option, + #[serde(default)] + pub error: Option, +} + +/// Transcript processing status +#[derive(Debug, Deserialize, PartialEq, Eq, Clone)] +#[serde(rename_all = "lowercase")] +pub enum TranscriptStatus { + Queued, + Processing, + Completed, + Error, +} + +/// Word with timing information +#[derive(Debug, Deserialize, Clone)] +pub struct Word { + pub text: String, + pub start: i64, + pub end: i64, + pub confidence: f64, + #[serde(default)] + pub speaker: Option, +} + +/// Utterance (speaker segment) with timing +#[derive(Debug, Deserialize, Clone)] +pub struct Utterance { + pub text: String, + pub start: i64, + pub end: i64, + pub confidence: f64, + pub speaker: String, + #[serde(default)] + pub words: Option>, +} + +/// Auto-generated chapter +#[derive(Debug, Deserialize, Clone)] +pub struct Chapter { + pub summary: String, + pub headline: String, + pub start: i64, + pub end: i64, + pub gist: String, +} + +/// Sentiment analysis result +#[derive(Debug, Deserialize, Clone)] +pub struct SentimentResult { + pub text: String, + pub start: i64, + pub end: i64, + pub sentiment: Sentiment, + pub confidence: f64, + #[serde(default)] + pub speaker: Option, +} + +/// Sentiment classification +#[derive(Debug, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "UPPERCASE")] +pub enum Sentiment { + Positive, + Neutral, + Negative, +} + +/// AssemblyAI API client +pub struct AssemblyAiClient { + client: reqwest::Client, + base_url: String, +} + +impl AssemblyAiClient { + /// Create a new AssemblyAI client with the given API key and base URL + pub fn new(api_key: &str, base_url: &str) -> Result { + let mut headers = reqwest::header::HeaderMap::new(); + + let mut header_value = reqwest::header::HeaderValue::from_str(api_key).map_err(|e| { + warn!("Failed to create auth header: {:?}", e); + Error { + source: Some(Box::new(e)), + error_kind: DomainErrorKind::Internal(InternalErrorKind::Other( + "Invalid API key format".to_string(), + )), + } + })?; + header_value.set_sensitive(true); + headers.insert("authorization", header_value); + + let client = reqwest::Client::builder() + .use_rustls_tls() + .default_headers(headers) + .build()?; + + Ok(Self { + client, + base_url: base_url.to_string(), + }) + } + + /// Verify the API key is valid by making a test request + pub async fn verify_api_key(&self) -> Result { + let url = format!("{}/transcript", self.base_url); + + let response = self.client.get(&url).send().await.map_err(|e| { + warn!("Failed to verify AssemblyAI API key: {:?}", e); + Error { + source: Some(Box::new(e)), + error_kind: DomainErrorKind::External(ExternalErrorKind::Network), + } + })?; + + // 200 means valid key (returns list of transcripts) + // 401 means invalid key + Ok(response.status().is_success()) + } + + /// Create a new transcription request + pub async fn create_transcript( + &self, + request: CreateTranscriptRequest, + ) -> Result { + let url = format!("{}/transcript", self.base_url); + + debug!( + "Creating AssemblyAI transcript for audio: {}", + request.audio_url + ); + + let response = self + .client + .post(&url) + .json(&request) + .send() + .await + .map_err(|e| { + warn!("Failed to create AssemblyAI transcript: {:?}", e); + Error { + source: Some(Box::new(e)), + error_kind: DomainErrorKind::External(ExternalErrorKind::Network), + } + })?; + + if response.status().is_success() { + let transcript: TranscriptResponse = response.json().await.map_err(|e| { + warn!("Failed to parse AssemblyAI response: {:?}", e); + Error { + source: Some(Box::new(e)), + error_kind: DomainErrorKind::External(ExternalErrorKind::Other( + "Invalid response from AssemblyAI".to_string(), + )), + } + })?; + info!("Created AssemblyAI transcript with ID: {}", transcript.id); + Ok(transcript) + } else { + let error_text = response.text().await.unwrap_or_default(); + warn!("AssemblyAI API error: {}", error_text); + Err(Error { + source: None, + error_kind: DomainErrorKind::External(ExternalErrorKind::Other(error_text)), + }) + } + } + + /// Get the status of a transcript + pub async fn get_transcript(&self, transcript_id: &str) -> Result { + let url = format!("{}/transcript/{}", self.base_url, transcript_id); + + let response = self.client.get(&url).send().await.map_err(|e| { + warn!("Failed to get AssemblyAI transcript: {:?}", e); + Error { + source: Some(Box::new(e)), + error_kind: DomainErrorKind::External(ExternalErrorKind::Network), + } + })?; + + if response.status().is_success() { + let transcript: TranscriptResponse = response.json().await.map_err(|e| { + warn!("Failed to parse AssemblyAI response: {:?}", e); + Error { + source: Some(Box::new(e)), + error_kind: DomainErrorKind::External(ExternalErrorKind::Other( + "Invalid response from AssemblyAI".to_string(), + )), + } + })?; + Ok(transcript) + } else { + let error_text = response.text().await.unwrap_or_default(); + warn!("AssemblyAI API error: {}", error_text); + Err(Error { + source: None, + error_kind: DomainErrorKind::External(ExternalErrorKind::Other(error_text)), + }) + } + } + + /// Delete a transcript + pub async fn delete_transcript(&self, transcript_id: &str) -> Result<(), Error> { + let url = format!("{}/transcript/{}", self.base_url, transcript_id); + + let response = self.client.delete(&url).send().await.map_err(|e| { + warn!("Failed to delete AssemblyAI transcript: {:?}", e); + Error { + source: Some(Box::new(e)), + error_kind: DomainErrorKind::External(ExternalErrorKind::Network), + } + })?; + + if response.status().is_success() { + info!("Deleted AssemblyAI transcript: {}", transcript_id); + Ok(()) + } else { + let error_text = response.text().await.unwrap_or_default(); + warn!("Failed to delete AssemblyAI transcript: {}", error_text); + Err(Error { + source: None, + error_kind: DomainErrorKind::External(ExternalErrorKind::Other(error_text)), + }) + } + } +} + +/// Helper to create a standard transcript request with webhook configuration +pub fn create_standard_transcript_request( + audio_url: String, + webhook_url: Option, + webhook_secret: Option, +) -> CreateTranscriptRequest { + CreateTranscriptRequest { + audio_url, + webhook_url: webhook_url.clone(), + webhook_auth_header_name: webhook_url.as_ref().map(|_| "X-Webhook-Secret".to_string()), + webhook_auth_header_value: webhook_secret, + speaker_labels: true, + sentiment_analysis: true, + auto_chapters: true, + entity_detection: true, + } +} + +/// Extract action items from transcript chapters and sentiment +/// This is a simple extraction - in production, you might use a more sophisticated approach +pub fn extract_action_items(transcript: &TranscriptResponse) -> Vec { + let mut actions = Vec::new(); + + // Look for action-related phrases in the transcript text + if let Some(text) = &transcript.text { + let action_keywords = [ + "i will", + "i'll", + "we will", + "we'll", + "going to", + "need to", + "should", + "must", + "have to", + "by friday", + "by monday", + "next week", + "tomorrow", + ]; + + for sentence in text.split(['.', '!', '?']) { + let lower = sentence.to_lowercase(); + if action_keywords.iter().any(|kw| lower.contains(kw)) { + actions.push(sentence.trim().to_string()); + } + } + } + + actions +} diff --git a/domain/src/gateway/google_oauth.rs b/domain/src/gateway/google_oauth.rs new file mode 100644 index 00000000..121dd47e --- /dev/null +++ b/domain/src/gateway/google_oauth.rs @@ -0,0 +1,372 @@ +//! Google OAuth and Meet API client. +//! +//! This module provides an HTTP client for interacting with Google OAuth +//! and the Google Meet API to create meeting spaces. + +use crate::error::{DomainErrorKind, Error, ExternalErrorKind, InternalErrorKind}; +use log::*; +use serde::{Deserialize, Serialize}; + +/// OAuth token response from Google +#[derive(Debug, Deserialize)] +pub struct TokenResponse { + pub access_token: String, + pub refresh_token: Option, + pub expires_in: i64, + pub token_type: String, + #[serde(default)] + pub scope: String, +} + +/// User info from Google +#[derive(Debug, Deserialize)] +pub struct GoogleUserInfo { + pub id: String, + pub email: String, + #[serde(default)] + pub name: Option, + #[serde(default)] + pub picture: Option, +} + +/// Request to exchange authorization code for tokens +#[derive(Debug, Serialize)] +struct TokenExchangeRequest { + code: String, + client_id: String, + client_secret: String, + redirect_uri: String, + grant_type: String, +} + +/// Request to refresh access token +#[derive(Debug, Serialize)] +struct TokenRefreshRequest { + refresh_token: String, + client_id: String, + client_secret: String, + grant_type: String, +} + +/// Google Meet space configuration +#[derive(Debug, Serialize)] +pub struct SpaceConfig { + #[serde(rename = "accessType")] + pub access_type: String, +} + +/// Request to create a Google Meet space +#[derive(Debug, Serialize)] +pub struct CreateSpaceRequest { + pub config: SpaceConfig, +} + +/// Response from creating a Google Meet space +#[derive(Debug, Deserialize)] +pub struct SpaceResponse { + pub name: String, + #[serde(rename = "meetingUri")] + pub meeting_uri: String, + #[serde(rename = "meetingCode")] + pub meeting_code: String, +} + +/// Configuration for Google OAuth URLs +#[derive(Debug, Clone)] +pub struct GoogleOAuthUrls { + pub auth_url: String, + pub token_url: String, + pub userinfo_url: String, +} + +/// Google OAuth client for handling authentication and Meet API +pub struct GoogleOAuthClient { + client: reqwest::Client, + client_id: String, + client_secret: String, + redirect_uri: String, + urls: GoogleOAuthUrls, +} + +impl GoogleOAuthClient { + /// Create a new Google OAuth client with configurable URLs + pub fn new( + client_id: &str, + client_secret: &str, + redirect_uri: &str, + urls: GoogleOAuthUrls, + ) -> Result { + let client = reqwest::Client::builder().use_rustls_tls().build()?; + + Ok(Self { + client, + client_id: client_id.to_string(), + client_secret: client_secret.to_string(), + redirect_uri: redirect_uri.to_string(), + urls, + }) + } + + /// Generate the OAuth authorization URL for user consent + pub fn get_authorization_url(&self, state: &str) -> String { + let scopes = [ + "openid", + "email", + "profile", + "https://www.googleapis.com/auth/meetings.space.created", + ] + .join(" "); + + format!( + "{}?\ + client_id={}&\ + redirect_uri={}&\ + response_type=code&\ + scope={}&\ + access_type=offline&\ + prompt=consent&\ + state={}", + self.urls.auth_url, + urlencoding::encode(&self.client_id), + urlencoding::encode(&self.redirect_uri), + urlencoding::encode(&scopes), + urlencoding::encode(state) + ) + } + + /// Exchange authorization code for access and refresh tokens + pub async fn exchange_code(&self, code: &str) -> Result { + let request = TokenExchangeRequest { + code: code.to_string(), + client_id: self.client_id.clone(), + client_secret: self.client_secret.clone(), + redirect_uri: self.redirect_uri.clone(), + grant_type: "authorization_code".to_string(), + }; + + debug!("Exchanging Google OAuth code for tokens"); + + let response = self + .client + .post(&self.urls.token_url) + .form(&request) + .send() + .await + .map_err(|e| { + warn!("Failed to exchange Google OAuth code: {:?}", e); + Error { + source: Some(Box::new(e)), + error_kind: DomainErrorKind::External(ExternalErrorKind::Network), + } + })?; + + if response.status().is_success() { + let tokens: TokenResponse = response.json().await.map_err(|e| { + warn!("Failed to parse Google token response: {:?}", e); + Error { + source: Some(Box::new(e)), + error_kind: DomainErrorKind::External(ExternalErrorKind::Other( + "Invalid response from Google OAuth".to_string(), + )), + } + })?; + info!("Successfully exchanged Google OAuth code for tokens"); + Ok(tokens) + } else { + let error_text = response.text().await.unwrap_or_default(); + warn!("Google OAuth error: {}", error_text); + Err(Error { + source: None, + error_kind: DomainErrorKind::External(ExternalErrorKind::Other(error_text)), + }) + } + } + + /// Refresh an expired access token using the refresh token + pub async fn refresh_token(&self, refresh_token: &str) -> Result { + let request = TokenRefreshRequest { + refresh_token: refresh_token.to_string(), + client_id: self.client_id.clone(), + client_secret: self.client_secret.clone(), + grant_type: "refresh_token".to_string(), + }; + + debug!("Refreshing Google access token"); + + let response = self + .client + .post(&self.urls.token_url) + .form(&request) + .send() + .await + .map_err(|e| { + warn!("Failed to refresh Google token: {:?}", e); + Error { + source: Some(Box::new(e)), + error_kind: DomainErrorKind::External(ExternalErrorKind::Network), + } + })?; + + if response.status().is_success() { + let tokens: TokenResponse = response.json().await.map_err(|e| { + warn!("Failed to parse Google token refresh response: {:?}", e); + Error { + source: Some(Box::new(e)), + error_kind: DomainErrorKind::External(ExternalErrorKind::Other( + "Invalid response from Google OAuth".to_string(), + )), + } + })?; + info!("Successfully refreshed Google access token"); + Ok(tokens) + } else { + let error_text = response.text().await.unwrap_or_default(); + warn!("Google token refresh error: {}", error_text); + Err(Error { + source: None, + error_kind: DomainErrorKind::External(ExternalErrorKind::Other(error_text)), + }) + } + } + + /// Get user info using the access token + pub async fn get_user_info(&self, access_token: &str) -> Result { + let response = self + .client + .get(&self.urls.userinfo_url) + .bearer_auth(access_token) + .send() + .await + .map_err(|e| { + warn!("Failed to get Google user info: {:?}", e); + Error { + source: Some(Box::new(e)), + error_kind: DomainErrorKind::External(ExternalErrorKind::Network), + } + })?; + + if response.status().is_success() { + let user_info: GoogleUserInfo = response.json().await.map_err(|e| { + warn!("Failed to parse Google user info: {:?}", e); + Error { + source: Some(Box::new(e)), + error_kind: DomainErrorKind::External(ExternalErrorKind::Other( + "Invalid response from Google".to_string(), + )), + } + })?; + Ok(user_info) + } else { + let error_text = response.text().await.unwrap_or_default(); + warn!("Google user info error: {}", error_text); + Err(Error { + source: None, + error_kind: DomainErrorKind::External(ExternalErrorKind::Other(error_text)), + }) + } + } + + /// Verify if an access token is still valid + pub async fn verify_token(&self, access_token: &str) -> Result { + let response = self + .client + .get(&self.urls.userinfo_url) + .bearer_auth(access_token) + .send() + .await + .map_err(|e| { + warn!("Failed to verify Google token: {:?}", e); + Error { + source: Some(Box::new(e)), + error_kind: DomainErrorKind::External(ExternalErrorKind::Network), + } + })?; + + Ok(response.status().is_success()) + } +} + +/// Google Meet API client for creating meeting spaces +pub struct GoogleMeetClient { + client: reqwest::Client, + base_url: String, +} + +impl GoogleMeetClient { + /// Create a new Google Meet client with the given access token and base URL + pub fn new(access_token: &str, base_url: &str) -> Result { + let mut headers = reqwest::header::HeaderMap::new(); + + let auth_value = format!("Bearer {}", access_token); + let mut header_value = + reqwest::header::HeaderValue::from_str(&auth_value).map_err(|e| { + warn!("Failed to create auth header: {:?}", e); + Error { + source: Some(Box::new(e)), + error_kind: DomainErrorKind::Internal(InternalErrorKind::Other( + "Invalid access token format".to_string(), + )), + } + })?; + header_value.set_sensitive(true); + headers.insert(reqwest::header::AUTHORIZATION, header_value); + + let client = reqwest::Client::builder() + .use_rustls_tls() + .default_headers(headers) + .build()?; + + Ok(Self { + client, + base_url: base_url.to_string(), + }) + } + + /// Create a new Google Meet space + pub async fn create_space(&self) -> Result { + let url = format!("{}/spaces", self.base_url); + + let request = CreateSpaceRequest { + config: SpaceConfig { + access_type: "OPEN".to_string(), + }, + }; + + debug!("Creating Google Meet space"); + + let response = self + .client + .post(&url) + .json(&request) + .send() + .await + .map_err(|e| { + warn!("Failed to create Google Meet space: {:?}", e); + Error { + source: Some(Box::new(e)), + error_kind: DomainErrorKind::External(ExternalErrorKind::Network), + } + })?; + + if response.status().is_success() { + let space: SpaceResponse = response.json().await.map_err(|e| { + warn!("Failed to parse Google Meet response: {:?}", e); + Error { + source: Some(Box::new(e)), + error_kind: DomainErrorKind::External(ExternalErrorKind::Other( + "Invalid response from Google Meet API".to_string(), + )), + } + })?; + info!("Created Google Meet space: {}", space.meeting_code); + Ok(space) + } else { + let error_text = response.text().await.unwrap_or_default(); + warn!("Google Meet API error: {}", error_text); + Err(Error { + source: None, + error_kind: DomainErrorKind::External(ExternalErrorKind::Other(error_text)), + }) + } + } +} diff --git a/domain/src/gateway/mod.rs b/domain/src/gateway/mod.rs index df443a08..d844b3ba 100644 --- a/domain/src/gateway/mod.rs +++ b/domain/src/gateway/mod.rs @@ -1,2 +1,5 @@ +pub mod assembly_ai; +pub mod google_oauth; pub(crate) mod mailersend; +pub mod recall_ai; pub(crate) mod tiptap; diff --git a/domain/src/gateway/recall_ai.rs b/domain/src/gateway/recall_ai.rs new file mode 100644 index 00000000..f352e690 --- /dev/null +++ b/domain/src/gateway/recall_ai.rs @@ -0,0 +1,445 @@ +//! Recall.ai API client for meeting recording bot management. +//! +//! This module provides an HTTP client for interacting with the Recall.ai API +//! to manage meeting recording bots for Google Meet sessions. + +use crate::error::{DomainErrorKind, Error, ExternalErrorKind, InternalErrorKind}; +use log::*; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; + +/// Recall.ai API regions +#[derive(Debug, Clone, Default)] +pub enum RecallRegion { + #[default] + UsWest2, + UsEast1, + EuWest1, +} + +impl RecallRegion { + /// Returns the region code (e.g., "us-west-2") + pub fn as_str(&self) -> &'static str { + match self { + RecallRegion::UsWest2 => "us-west-2", + RecallRegion::UsEast1 => "us-east-1", + RecallRegion::EuWest1 => "eu-west-1", + } + } + + /// Constructs the full base URL using the given base domain + pub fn base_url(&self, base_domain: &str) -> String { + format!("https://{}.{}", self.as_str(), base_domain) + } +} + +impl FromStr for RecallRegion { + type Err = std::convert::Infallible; + + fn from_str(s: &str) -> Result { + Ok(match s.to_lowercase().as_str() { + "us-east-1" => RecallRegion::UsEast1, + "eu-west-1" => RecallRegion::EuWest1, + _ => RecallRegion::UsWest2, + }) + } +} + +/// Request to create a new recording bot +#[derive(Debug, Serialize)] +pub struct CreateBotRequest { + pub meeting_url: String, + pub bot_name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub recording_config: Option, +} + +/// Recording configuration for the bot +#[derive(Debug, Serialize)] +pub struct RecordingConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub transcript: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub realtime_endpoints: Option>, +} + +/// Transcript configuration +#[derive(Debug, Serialize)] +pub struct TranscriptConfig { + pub provider: TranscriptProvider, + #[serde(skip_serializing_if = "Option::is_none")] + pub diarization: Option, +} + +/// Transcript provider configuration +#[derive(Debug, Serialize)] +pub struct TranscriptProvider { + pub recallai_streaming: StreamingMode, +} + +/// Streaming mode for transcription +#[derive(Debug, Serialize)] +pub struct StreamingMode { + pub mode: String, +} + +/// Diarization (speaker identification) configuration +#[derive(Debug, Serialize)] +pub struct DiarizationConfig { + pub use_separate_streams_when_available: bool, +} + +/// Realtime webhook endpoint configuration +#[derive(Debug, Serialize)] +pub struct RealtimeEndpoint { + #[serde(rename = "type")] + pub endpoint_type: String, + pub url: String, + pub events: Vec, +} + +/// Meeting URL info returned by Recall.ai +/// Note: This is an object, not a plain string URL +#[derive(Debug, Deserialize)] +pub struct MeetingUrlInfo { + /// The meeting ID extracted from the URL + pub meeting_id: String, + /// The meeting platform (e.g., "google_meet", "zoom", "teams") + pub platform: String, +} + +/// Response from creating a bot +/// Note: The Recall.ai API returns many fields - we only capture what we need +#[derive(Debug, Deserialize)] +pub struct CreateBotResponse { + /// Bot ID (could be "id" or "bot_id" depending on endpoint) + #[serde(alias = "bot_id")] + pub id: String, + /// Meeting URL info (object with meeting_id and platform) + #[serde(default)] + pub meeting_url: Option, + /// Bot name + #[serde(default)] + pub bot_name: Option, + /// Status changes (empty array on creation) + #[serde(default)] + pub status_changes: Vec, +} + +/// Bot status change +#[derive(Debug, Deserialize)] +pub struct StatusChange { + pub code: String, + #[serde(default)] + pub created_at: Option, +} + +/// Bot status response +#[derive(Debug, Deserialize)] +pub struct BotStatusResponse { + pub id: String, + pub status_changes: Vec, + /// Recordings array containing media artifacts + #[serde(default)] + pub recordings: Vec, + #[serde(default)] + pub meeting_metadata: Option, +} + +impl BotStatusResponse { + /// Extract the video download URL from the nested recordings structure + pub fn video_url(&self) -> Option { + self.recordings + .first() + .and_then(|r| r.media_shortcuts.as_ref()) + .and_then(|ms| ms.video_mixed.as_ref()) + .and_then(|vm| vm.data.as_ref()) + .map(|d| d.download_url.clone()) + } + + /// Extract duration from the first recording + pub fn duration_seconds(&self) -> Option { + self.recordings + .first() + .and_then(|r| match (&r.started_at, &r.completed_at) { + (Some(start), Some(end)) => { + if let (Ok(s), Ok(e)) = ( + chrono::DateTime::parse_from_rfc3339(start), + chrono::DateTime::parse_from_rfc3339(end), + ) { + Some((e - s).num_seconds() as i32) + } else { + None + } + } + _ => None, + }) + } +} + +/// Recording object from Recall.ai +#[derive(Debug, Deserialize)] +pub struct Recording { + pub id: String, + #[serde(default)] + pub started_at: Option, + #[serde(default)] + pub completed_at: Option, + #[serde(default)] + pub status: Option, + #[serde(default)] + pub media_shortcuts: Option, +} + +/// Recording status info +#[derive(Debug, Deserialize)] +pub struct RecordingStatusInfo { + pub code: String, + #[serde(default)] + pub sub_code: Option, +} + +/// Media shortcuts containing video/audio artifacts +#[derive(Debug, Deserialize)] +pub struct MediaShortcuts { + #[serde(default)] + pub video_mixed: Option, +} + +/// Individual media artifact +#[derive(Debug, Deserialize)] +pub struct MediaArtifact { + pub id: String, + #[serde(default)] + pub status: Option, + #[serde(default)] + pub data: Option, + #[serde(default)] + pub format: Option, +} + +/// Media data containing the download URL +#[derive(Debug, Deserialize)] +pub struct MediaData { + pub download_url: String, +} + +/// Meeting metadata from the bot +#[derive(Debug, Deserialize)] +pub struct MeetingMetadata { + #[serde(default)] + pub title: Option, + #[serde(default)] + pub duration: Option, +} + +/// Recall.ai API client +pub struct RecallAiClient { + client: reqwest::Client, + base_url: String, +} + +impl RecallAiClient { + /// Create a new Recall.ai client with the given API key, region, and base domain + pub fn new(api_key: &str, region: RecallRegion, base_domain: &str) -> Result { + let mut headers = reqwest::header::HeaderMap::new(); + + let auth_value = format!("Token {}", api_key); + let mut header_value = + reqwest::header::HeaderValue::from_str(&auth_value).map_err(|e| { + warn!("Failed to create auth header: {:?}", e); + Error { + source: Some(Box::new(e)), + error_kind: DomainErrorKind::Internal(InternalErrorKind::Other( + "Invalid API key format".to_string(), + )), + } + })?; + header_value.set_sensitive(true); + headers.insert(reqwest::header::AUTHORIZATION, header_value); + + let client = reqwest::Client::builder() + .use_rustls_tls() + .default_headers(headers) + .build()?; + + Ok(Self { + client, + base_url: region.base_url(base_domain), + }) + } + + /// Verify the API key is valid by making a test request + pub async fn verify_api_key(&self) -> Result { + let url = format!("{}/api/v1/bot/", self.base_url); + + let response = self.client.get(&url).send().await.map_err(|e| { + warn!("Failed to verify Recall.ai API key: {:?}", e); + Error { + source: Some(Box::new(e)), + error_kind: DomainErrorKind::External(ExternalErrorKind::Network), + } + })?; + + // 200 or 401 both indicate the API is reachable + // 401 means invalid key, 200 means valid key + Ok(response.status().is_success()) + } + + /// Create a new recording bot for a meeting + pub async fn create_bot(&self, request: CreateBotRequest) -> Result { + let url = format!("{}/api/v1/bot/", self.base_url); + + debug!( + "Creating Recall.ai bot for meeting: {}", + request.meeting_url + ); + + let response = self + .client + .post(&url) + .json(&request) + .send() + .await + .map_err(|e| { + warn!("Failed to create Recall.ai bot: {:?}", e); + Error { + source: Some(Box::new(e)), + error_kind: DomainErrorKind::External(ExternalErrorKind::Network), + } + })?; + + if response.status().is_success() { + // Get raw text first for debugging + let response_text = response.text().await.map_err(|e| { + warn!("Failed to read Recall.ai response body: {:?}", e); + Error { + source: Some(Box::new(e)), + error_kind: DomainErrorKind::External(ExternalErrorKind::Network), + } + })?; + + debug!("Recall.ai raw response: {}", response_text); + + let bot: CreateBotResponse = serde_json::from_str(&response_text).map_err(|e| { + let error_msg = format!("Invalid response from Recall.ai: {}", e); + warn!( + "Failed to parse Recall.ai response: {:?}. Raw response: {}", + e, response_text + ); + Error { + source: Some(Box::new(e)), + error_kind: DomainErrorKind::External(ExternalErrorKind::Other(error_msg)), + } + })?; + info!("Created Recall.ai bot with ID: {}", bot.id); + Ok(bot) + } else { + let status = response.status(); + let error_text = response.text().await.unwrap_or_default(); + warn!("Recall.ai API error ({}): {}", status, error_text); + Err(Error { + source: None, + error_kind: DomainErrorKind::External(ExternalErrorKind::Other(format!( + "Recall.ai API error ({}): {}", + status, error_text + ))), + }) + } + } + + /// Get the status of a bot + pub async fn get_bot_status(&self, bot_id: &str) -> Result { + let url = format!("{}/api/v1/bot/{}/", self.base_url, bot_id); + + let response = self.client.get(&url).send().await.map_err(|e| { + warn!("Failed to get Recall.ai bot status: {:?}", e); + Error { + source: Some(Box::new(e)), + error_kind: DomainErrorKind::External(ExternalErrorKind::Network), + } + })?; + + if response.status().is_success() { + let status: BotStatusResponse = response.json().await.map_err(|e| { + warn!("Failed to parse Recall.ai status response: {:?}", e); + Error { + source: Some(Box::new(e)), + error_kind: DomainErrorKind::External(ExternalErrorKind::Other( + "Invalid response from Recall.ai".to_string(), + )), + } + })?; + Ok(status) + } else { + let error_text = response.text().await.unwrap_or_default(); + warn!("Recall.ai API error: {}", error_text); + Err(Error { + source: None, + error_kind: DomainErrorKind::External(ExternalErrorKind::Other(error_text)), + }) + } + } + + /// Stop a recording bot + pub async fn stop_bot(&self, bot_id: &str) -> Result<(), Error> { + let url = format!("{}/api/v1/bot/{}/leave_call/", self.base_url, bot_id); + + debug!("Stopping Recall.ai bot: {}", bot_id); + + let response = self.client.post(&url).send().await.map_err(|e| { + warn!("Failed to stop Recall.ai bot: {:?}", e); + Error { + source: Some(Box::new(e)), + error_kind: DomainErrorKind::External(ExternalErrorKind::Network), + } + })?; + + if response.status().is_success() { + info!("Stopped Recall.ai bot: {}", bot_id); + Ok(()) + } else { + let error_text = response.text().await.unwrap_or_default(); + warn!("Failed to stop Recall.ai bot: {}", error_text); + Err(Error { + source: None, + error_kind: DomainErrorKind::External(ExternalErrorKind::Other(error_text)), + }) + } + } +} + +/// Helper to create a standard bot request with webhook configuration +pub fn create_standard_bot_request( + meeting_url: String, + bot_name: String, + webhook_url: Option, +) -> CreateBotRequest { + let recording_config = webhook_url.map(|url| RecordingConfig { + transcript: Some(TranscriptConfig { + provider: TranscriptProvider { + recallai_streaming: StreamingMode { + mode: "prioritize_accuracy".to_string(), + }, + }, + diarization: Some(DiarizationConfig { + use_separate_streams_when_available: true, + }), + }), + realtime_endpoints: Some(vec![RealtimeEndpoint { + endpoint_type: "webhook".to_string(), + url, + events: vec![ + // Real-time transcript events (during recording) + "transcript.data".to_string(), + "transcript.partial_data".to_string(), + ], + }]), + }); + + CreateBotRequest { + meeting_url, + bot_name, + recording_config, + } +} diff --git a/domain/src/lib.rs b/domain/src/lib.rs index b33c0e35..6bcbdc74 100644 --- a/domain/src/lib.rs +++ b/domain/src/lib.rs @@ -15,11 +15,20 @@ pub use entity_api::{ organizations, overarching_goals, query::QuerySort, status, user_roles, users, Id, }; +// AI Meeting Integration re-exports +pub use entity_api::{ + ai_privacy_level, ai_suggested_items, ai_suggestion, meeting_recording, + meeting_recording_status, meeting_recordings, sentiment, transcript_segment, + transcript_segments, transcription, transcription_status, transcriptions, user_integration, + user_integrations, +}; + pub mod action; pub mod agreement; pub mod coaching_relationship; pub mod coaching_session; pub mod emails; +pub mod encryption; pub mod error; pub mod jwt; pub mod note; @@ -27,4 +36,4 @@ pub mod organization; pub mod overarching_goal; pub mod user; -pub(crate) mod gateway; +pub mod gateway; diff --git a/domain/src/user.rs b/domain/src/user.rs index e4e32ec3..ebd06940 100644 --- a/domain/src/user.rs +++ b/domain/src/user.rs @@ -114,6 +114,8 @@ pub async fn create_user_and_coaching_relationship( organization_id: Default::default(), id: Default::default(), slug: "".to_string(), + meeting_url: None, + ai_privacy_level: entity_api::ai_privacy_level::AiPrivacyLevel::Full, created_at: Utc::now().into(), updated_at: Utc::now().into(), }; diff --git a/entity/src/ai_privacy_level.rs b/entity/src/ai_privacy_level.rs new file mode 100644 index 00000000..97389899 --- /dev/null +++ b/entity/src/ai_privacy_level.rs @@ -0,0 +1,32 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +/// Per-relationship privacy setting for AI features. +/// Allows coaches to configure AI integration on a per-client basis. +#[derive( + Debug, Clone, Eq, PartialEq, EnumIter, Deserialize, Default, Serialize, DeriveActiveEnum, +)] +#[serde(rename_all = "snake_case")] +#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "ai_privacy_level")] +pub enum AiPrivacyLevel { + /// No AI recording or transcribing - for clients uncomfortable with AI + #[sea_orm(string_value = "none")] + None, + /// Text transcription only, no video/audio storage + #[sea_orm(string_value = "transcribe_only")] + TranscribeOnly, + /// All AI recording and transcribing features enabled + #[sea_orm(string_value = "full")] + #[default] + Full, +} + +impl std::fmt::Display for AiPrivacyLevel { + fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AiPrivacyLevel::None => write!(fmt, "none"), + AiPrivacyLevel::TranscribeOnly => write!(fmt, "transcribe_only"), + AiPrivacyLevel::Full => write!(fmt, "full"), + } + } +} diff --git a/entity/src/ai_suggested_items.rs b/entity/src/ai_suggested_items.rs new file mode 100644 index 00000000..7367db1a --- /dev/null +++ b/entity/src/ai_suggested_items.rs @@ -0,0 +1,67 @@ +//! SeaORM Entity for ai_suggested_items table. +//! Stores AI-detected action items and agreements before user approval. + +use crate::ai_suggestion::{AiSuggestionStatus, AiSuggestionType}; +use crate::Id; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize, ToSchema)] +#[schema(as = entity::ai_suggested_items::Model)] +#[sea_orm(schema_name = "refactor_platform", table_name = "ai_suggested_items")] +pub struct Model { + #[serde(skip_deserializing)] + #[sea_orm(primary_key)] + pub id: Id, + + pub transcription_id: Id, + + /// Type of suggestion (action or agreement) + pub item_type: AiSuggestionType, + + /// The suggested content/text + #[sea_orm(column_type = "Text")] + pub content: String, + + /// Original transcript text this was extracted from + #[sea_orm(column_type = "Text")] + pub source_text: Option, + + /// Confidence score from AI (0.0 - 1.0) + pub confidence: Option, + + /// Current status of the suggestion + pub status: AiSuggestionStatus, + + /// ID of the created Action or Agreement entity after acceptance + pub accepted_entity_id: Option, + + #[serde(skip_deserializing)] + #[schema(value_type = String, format = DateTime)] + pub created_at: DateTimeWithTimeZone, + + #[serde(skip_deserializing)] + #[schema(value_type = String, format = DateTime)] + pub updated_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::transcriptions::Entity", + from = "Column::TranscriptionId", + to = "super::transcriptions::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Transcriptions, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Transcriptions.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/entity/src/ai_suggestion.rs b/entity/src/ai_suggestion.rs new file mode 100644 index 00000000..647b9a1f --- /dev/null +++ b/entity/src/ai_suggestion.rs @@ -0,0 +1,55 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +/// Type of AI-suggested item extracted from transcription. +#[derive(Debug, Clone, Eq, PartialEq, EnumIter, Deserialize, Serialize, DeriveActiveEnum)] +#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "ai_suggestion_type")] +pub enum AiSuggestionType { + /// Action item extracted from conversation + #[sea_orm(string_value = "action")] + Action, + /// Agreement extracted from conversation + #[sea_orm(string_value = "agreement")] + Agreement, +} + +impl std::fmt::Display for AiSuggestionType { + fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AiSuggestionType::Action => write!(fmt, "action"), + AiSuggestionType::Agreement => write!(fmt, "agreement"), + } + } +} + +/// Status of an AI-suggested item. +#[derive( + Debug, Clone, Eq, PartialEq, EnumIter, Deserialize, Default, Serialize, DeriveActiveEnum, +)] +#[sea_orm( + rs_type = "String", + db_type = "Enum", + enum_name = "ai_suggestion_status" +)] +pub enum AiSuggestionStatus { + /// Suggestion is pending user review + #[sea_orm(string_value = "pending")] + #[default] + Pending, + /// User accepted the suggestion (converted to real entity) + #[sea_orm(string_value = "accepted")] + Accepted, + /// User dismissed the suggestion + #[sea_orm(string_value = "dismissed")] + Dismissed, +} + +impl std::fmt::Display for AiSuggestionStatus { + fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AiSuggestionStatus::Pending => write!(fmt, "pending"), + AiSuggestionStatus::Accepted => write!(fmt, "accepted"), + AiSuggestionStatus::Dismissed => write!(fmt, "dismissed"), + } + } +} diff --git a/entity/src/coaching_relationships.rs b/entity/src/coaching_relationships.rs index 51e565b2..4d4d4c0d 100644 --- a/entity/src/coaching_relationships.rs +++ b/entity/src/coaching_relationships.rs @@ -1,5 +1,6 @@ //! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.3 +use crate::ai_privacy_level::AiPrivacyLevel; use crate::Id; use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; @@ -28,6 +29,13 @@ pub struct Model { // We'll need to add a migration for that eventually. #[sea_orm(unique)] pub slug: String, + + /// Google Meet URL for this coaching relationship + pub meeting_url: Option, + + /// AI privacy level for this coaching relationship + pub ai_privacy_level: AiPrivacyLevel, + #[serde(skip_deserializing)] #[schema(value_type = String, format = DateTime)] // Applies to OpenAPI schema pub created_at: DateTimeWithTimeZone, diff --git a/entity/src/lib.rs b/entity/src/lib.rs index 236fb09f..3d1082f1 100644 --- a/entity/src/lib.rs +++ b/entity/src/lib.rs @@ -2,6 +2,7 @@ use uuid::Uuid; pub mod prelude; +// Core entities pub mod actions; pub mod agreements; pub mod coachees; @@ -17,6 +18,18 @@ pub mod status; pub mod user_roles; pub mod users; +// AI Meeting Integration entities +pub mod ai_privacy_level; +pub mod ai_suggested_items; +pub mod ai_suggestion; +pub mod meeting_recording_status; +pub mod meeting_recordings; +pub mod sentiment; +pub mod transcript_segments; +pub mod transcription_status; +pub mod transcriptions; +pub mod user_integrations; + /// A type alias that represents any Entity's internal id field data type. /// Aliased so that it's easy to change the underlying type if necessary. pub type Id = Uuid; diff --git a/entity/src/meeting_recording_status.rs b/entity/src/meeting_recording_status.rs new file mode 100644 index 00000000..ac05ea14 --- /dev/null +++ b/entity/src/meeting_recording_status.rs @@ -0,0 +1,46 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +/// Status of a meeting recording through its lifecycle. +#[derive( + Debug, Clone, Eq, PartialEq, EnumIter, Deserialize, Default, Serialize, DeriveActiveEnum, +)] +#[sea_orm( + rs_type = "String", + db_type = "Enum", + enum_name = "meeting_recording_status" +)] +pub enum MeetingRecordingStatus { + /// Recording has been requested but bot hasn't joined yet + #[sea_orm(string_value = "pending")] + #[default] + Pending, + /// Bot is joining the meeting + #[sea_orm(string_value = "joining")] + Joining, + /// Actively recording the meeting + #[sea_orm(string_value = "recording")] + Recording, + /// Recording complete, processing/uploading + #[sea_orm(string_value = "processing")] + Processing, + /// Recording fully complete and available + #[sea_orm(string_value = "completed")] + Completed, + /// Recording failed at some stage + #[sea_orm(string_value = "failed")] + Failed, +} + +impl std::fmt::Display for MeetingRecordingStatus { + fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MeetingRecordingStatus::Pending => write!(fmt, "pending"), + MeetingRecordingStatus::Joining => write!(fmt, "joining"), + MeetingRecordingStatus::Recording => write!(fmt, "recording"), + MeetingRecordingStatus::Processing => write!(fmt, "processing"), + MeetingRecordingStatus::Completed => write!(fmt, "completed"), + MeetingRecordingStatus::Failed => write!(fmt, "failed"), + } + } +} diff --git a/entity/src/meeting_recordings.rs b/entity/src/meeting_recordings.rs new file mode 100644 index 00000000..351410df --- /dev/null +++ b/entity/src/meeting_recordings.rs @@ -0,0 +1,79 @@ +//! SeaORM Entity for meeting_recordings table. +//! Tracks meeting recordings from Recall.ai. + +use crate::meeting_recording_status::MeetingRecordingStatus; +use crate::Id; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize, ToSchema)] +#[schema(as = entity::meeting_recordings::Model)] +#[sea_orm(schema_name = "refactor_platform", table_name = "meeting_recordings")] +pub struct Model { + #[serde(skip_deserializing)] + #[sea_orm(primary_key)] + pub id: Id, + + pub coaching_session_id: Id, + + /// Recall.ai bot ID for this recording + pub recall_bot_id: Option, + + /// Current status of the recording + pub status: MeetingRecordingStatus, + + /// URL to the recording (after processing) + pub recording_url: Option, + + /// Duration of the recording in seconds + pub duration_seconds: Option, + + /// When the recording started + #[schema(value_type = Option, format = DateTime)] + pub started_at: Option, + + /// When the recording ended + #[schema(value_type = Option, format = DateTime)] + pub ended_at: Option, + + /// Error message if recording failed + pub error_message: Option, + + #[serde(skip_deserializing)] + #[schema(value_type = String, format = DateTime)] + pub created_at: DateTimeWithTimeZone, + + #[serde(skip_deserializing)] + #[schema(value_type = String, format = DateTime)] + pub updated_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::coaching_sessions::Entity", + from = "Column::CoachingSessionId", + to = "super::coaching_sessions::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + CoachingSessions, + + #[sea_orm(has_one = "super::transcriptions::Entity")] + Transcriptions, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::CoachingSessions.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Transcriptions.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/entity/src/prelude.rs b/entity/src/prelude.rs index a2a78f2e..492d9738 100644 --- a/entity/src/prelude.rs +++ b/entity/src/prelude.rs @@ -3,3 +3,10 @@ pub use super::coaching_relationships::Entity as CoachingRelationships; pub use super::organizations::Entity as Organizations; pub use super::users::Entity as Users; + +// AI Meeting Integration entities +pub use super::ai_suggested_items::Entity as AiSuggestedItems; +pub use super::meeting_recordings::Entity as MeetingRecordings; +pub use super::transcript_segments::Entity as TranscriptSegments; +pub use super::transcriptions::Entity as Transcriptions; +pub use super::user_integrations::Entity as UserIntegrations; diff --git a/entity/src/sentiment.rs b/entity/src/sentiment.rs new file mode 100644 index 00000000..ed899655 --- /dev/null +++ b/entity/src/sentiment.rs @@ -0,0 +1,24 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +/// Sentiment analysis result for a transcript segment. +#[derive(Debug, Clone, Eq, PartialEq, EnumIter, Deserialize, Serialize, DeriveActiveEnum)] +#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "sentiment")] +pub enum Sentiment { + #[sea_orm(string_value = "positive")] + Positive, + #[sea_orm(string_value = "neutral")] + Neutral, + #[sea_orm(string_value = "negative")] + Negative, +} + +impl std::fmt::Display for Sentiment { + fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Sentiment::Positive => write!(fmt, "positive"), + Sentiment::Neutral => write!(fmt, "neutral"), + Sentiment::Negative => write!(fmt, "negative"), + } + } +} diff --git a/entity/src/transcript_segments.rs b/entity/src/transcript_segments.rs new file mode 100644 index 00000000..2952f8a1 --- /dev/null +++ b/entity/src/transcript_segments.rs @@ -0,0 +1,80 @@ +//! SeaORM Entity for transcript_segments table. +//! Stores individual utterances with speaker diarization. + +use crate::sentiment::Sentiment; +use crate::Id; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize, ToSchema)] +#[schema(as = entity::transcript_segments::Model)] +#[sea_orm(schema_name = "refactor_platform", table_name = "transcript_segments")] +pub struct Model { + #[serde(skip_deserializing)] + #[sea_orm(primary_key)] + pub id: Id, + + pub transcription_id: Id, + + /// Speaker label from diarization (e.g., "Speaker A", "Speaker B") + pub speaker_label: String, + + /// Mapped user ID if speaker has been identified + pub speaker_user_id: Option, + + /// The spoken text for this segment + #[sea_orm(column_type = "Text")] + pub text: String, + + /// Start time in milliseconds from beginning of recording + pub start_time_ms: i64, + + /// End time in milliseconds from beginning of recording + pub end_time_ms: i64, + + /// Confidence score for this segment (0.0 - 1.0) + pub confidence: Option, + + /// Sentiment analysis result for this segment + pub sentiment: Option, + + #[serde(skip_deserializing)] + #[schema(value_type = String, format = DateTime)] + pub created_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::transcriptions::Entity", + from = "Column::TranscriptionId", + to = "super::transcriptions::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Transcriptions, + + #[sea_orm( + belongs_to = "super::users::Entity", + from = "Column::SpeakerUserId", + to = "super::users::Column::Id", + on_update = "NoAction", + on_delete = "NoAction" + )] + Users, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Transcriptions.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Users.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/entity/src/transcription_status.rs b/entity/src/transcription_status.rs new file mode 100644 index 00000000..ade3e6ce --- /dev/null +++ b/entity/src/transcription_status.rs @@ -0,0 +1,38 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +/// Status of a transcription through its lifecycle. +#[derive( + Debug, Clone, Eq, PartialEq, EnumIter, Deserialize, Default, Serialize, DeriveActiveEnum, +)] +#[sea_orm( + rs_type = "String", + db_type = "Enum", + enum_name = "transcription_status" +)] +pub enum TranscriptionStatus { + /// Transcription has been requested but not started + #[sea_orm(string_value = "pending")] + #[default] + Pending, + /// Transcription is being processed by AssemblyAI + #[sea_orm(string_value = "processing")] + Processing, + /// Transcription complete and available + #[sea_orm(string_value = "completed")] + Completed, + /// Transcription failed + #[sea_orm(string_value = "failed")] + Failed, +} + +impl std::fmt::Display for TranscriptionStatus { + fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TranscriptionStatus::Pending => write!(fmt, "pending"), + TranscriptionStatus::Processing => write!(fmt, "processing"), + TranscriptionStatus::Completed => write!(fmt, "completed"), + TranscriptionStatus::Failed => write!(fmt, "failed"), + } + } +} diff --git a/entity/src/transcriptions.rs b/entity/src/transcriptions.rs new file mode 100644 index 00000000..2911fb19 --- /dev/null +++ b/entity/src/transcriptions.rs @@ -0,0 +1,91 @@ +//! SeaORM Entity for transcriptions table. +//! Stores transcription data from AssemblyAI. + +use crate::transcription_status::TranscriptionStatus; +use crate::Id; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize, ToSchema)] +#[schema(as = entity::transcriptions::Model)] +#[sea_orm(schema_name = "refactor_platform", table_name = "transcriptions")] +pub struct Model { + #[serde(skip_deserializing)] + #[sea_orm(primary_key)] + pub id: Id, + + pub meeting_recording_id: Id, + + /// AssemblyAI transcript ID + pub assemblyai_transcript_id: Option, + + /// Current status of the transcription + pub status: TranscriptionStatus, + + /// Full transcription text + #[sea_orm(column_type = "Text")] + pub full_text: Option, + + /// AI-generated summary of the transcription + #[sea_orm(column_type = "Text")] + pub summary: Option, + + /// Confidence score from AssemblyAI (0.0 - 1.0) + pub confidence_score: Option, + + /// Total word count + pub word_count: Option, + + /// Language code (default: en) + pub language_code: Option, + + /// Error message if transcription failed + pub error_message: Option, + + #[serde(skip_deserializing)] + #[schema(value_type = String, format = DateTime)] + pub created_at: DateTimeWithTimeZone, + + #[serde(skip_deserializing)] + #[schema(value_type = String, format = DateTime)] + pub updated_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::meeting_recordings::Entity", + from = "Column::MeetingRecordingId", + to = "super::meeting_recordings::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + MeetingRecordings, + + #[sea_orm(has_many = "super::transcript_segments::Entity")] + TranscriptSegments, + + #[sea_orm(has_many = "super::ai_suggested_items::Entity")] + AiSuggestedItems, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::MeetingRecordings.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::TranscriptSegments.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::AiSuggestedItems.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/entity/src/user_integrations.rs b/entity/src/user_integrations.rs new file mode 100644 index 00000000..3a07cd19 --- /dev/null +++ b/entity/src/user_integrations.rs @@ -0,0 +1,61 @@ +//! SeaORM Entity for user_integrations table. +//! Stores encrypted API credentials for external service integrations. + +use crate::Id; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize, ToSchema)] +#[schema(as = entity::user_integrations::Model)] +#[sea_orm(schema_name = "refactor_platform", table_name = "user_integrations")] +pub struct Model { + #[serde(skip_deserializing)] + #[sea_orm(primary_key)] + pub id: Id, + + pub user_id: Id, + + // Google OAuth (encrypted in database) + pub google_access_token: Option, + pub google_refresh_token: Option, + pub google_token_expiry: Option, + pub google_email: Option, + + // Recall.ai (encrypted in database) + pub recall_ai_api_key: Option, + pub recall_ai_region: Option, + pub recall_ai_verified_at: Option, + + // AssemblyAI (encrypted in database) + pub assembly_ai_api_key: Option, + pub assembly_ai_verified_at: Option, + + #[serde(skip_deserializing)] + #[schema(value_type = String, format = DateTime)] + pub created_at: DateTimeWithTimeZone, + + #[serde(skip_deserializing)] + #[schema(value_type = String, format = DateTime)] + pub updated_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::users::Entity", + from = "Column::UserId", + to = "super::users::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Users, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Users.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/entity_api/src/ai_suggested_item.rs b/entity_api/src/ai_suggested_item.rs new file mode 100644 index 00000000..2bb15985 --- /dev/null +++ b/entity_api/src/ai_suggested_item.rs @@ -0,0 +1,158 @@ +//! CRUD operations for ai_suggested_items table. + +use super::error::{EntityApiErrorKind, Error}; +use entity::ai_suggested_items::{ActiveModel, Entity, Model}; +use entity::ai_suggestion::{AiSuggestionStatus, AiSuggestionType}; +use entity::Id; +use log::*; +use sea_orm::{ + entity::prelude::*, + ActiveValue::{Set, Unchanged}, + DatabaseConnection, TryIntoModel, +}; + +/// Creates a new AI suggested item +pub async fn create( + db: &DatabaseConnection, + transcription_id: Id, + item_type: AiSuggestionType, + content: String, + source_text: Option, + confidence: Option, +) -> Result { + debug!("Creating new AI suggestion for transcription: {transcription_id}"); + + let now = chrono::Utc::now(); + + let active_model = ActiveModel { + transcription_id: Set(transcription_id), + item_type: Set(item_type), + content: Set(content), + source_text: Set(source_text), + confidence: Set(confidence), + status: Set(AiSuggestionStatus::Pending), + created_at: Set(now.into()), + updated_at: Set(now.into()), + ..Default::default() + }; + + Ok(active_model.save(db).await?.try_into_model()?) +} + +/// Accepts an AI suggested item, linking it to the created entity +pub async fn accept( + db: &DatabaseConnection, + id: Id, + accepted_entity_id: Id, +) -> Result { + let result = Entity::find_by_id(id).one(db).await?; + + match result { + Some(existing) => { + debug!("Accepting AI suggestion: {id}"); + + let active_model = ActiveModel { + id: Unchanged(existing.id), + transcription_id: Unchanged(existing.transcription_id), + item_type: Unchanged(existing.item_type), + content: Unchanged(existing.content), + source_text: Unchanged(existing.source_text), + confidence: Unchanged(existing.confidence), + status: Set(AiSuggestionStatus::Accepted), + accepted_entity_id: Set(Some(accepted_entity_id)), + created_at: Unchanged(existing.created_at), + updated_at: Set(chrono::Utc::now().into()), + }; + + Ok(active_model.update(db).await?.try_into_model()?) + } + None => { + debug!("AI suggestion with id {id} not found"); + Err(Error { + source: None, + error_kind: EntityApiErrorKind::RecordNotFound, + }) + } + } +} + +/// Dismisses an AI suggested item +pub async fn dismiss(db: &DatabaseConnection, id: Id) -> Result { + let result = Entity::find_by_id(id).one(db).await?; + + match result { + Some(existing) => { + debug!("Dismissing AI suggestion: {id}"); + + let active_model = ActiveModel { + id: Unchanged(existing.id), + transcription_id: Unchanged(existing.transcription_id), + item_type: Unchanged(existing.item_type), + content: Unchanged(existing.content), + source_text: Unchanged(existing.source_text), + confidence: Unchanged(existing.confidence), + status: Set(AiSuggestionStatus::Dismissed), + accepted_entity_id: Unchanged(existing.accepted_entity_id), + created_at: Unchanged(existing.created_at), + updated_at: Set(chrono::Utc::now().into()), + }; + + Ok(active_model.update(db).await?.try_into_model()?) + } + None => Err(Error { + source: None, + error_kind: EntityApiErrorKind::RecordNotFound, + }), + } +} + +/// Finds an AI suggested item by ID +pub async fn find_by_id(db: &DatabaseConnection, id: Id) -> Result { + Entity::find_by_id(id).one(db).await?.ok_or_else(|| Error { + source: None, + error_kind: EntityApiErrorKind::RecordNotFound, + }) +} + +/// Finds all AI suggestions for a transcription +pub async fn find_by_transcription_id( + db: &DatabaseConnection, + transcription_id: Id, +) -> Result, Error> { + Ok(Entity::find() + .filter(entity::ai_suggested_items::Column::TranscriptionId.eq(transcription_id)) + .all(db) + .await?) +} + +/// Finds pending AI suggestions for a transcription +pub async fn find_pending_by_transcription_id( + db: &DatabaseConnection, + transcription_id: Id, +) -> Result, Error> { + Ok(Entity::find() + .filter(entity::ai_suggested_items::Column::TranscriptionId.eq(transcription_id)) + .filter(entity::ai_suggested_items::Column::Status.eq(AiSuggestionStatus::Pending)) + .all(db) + .await?) +} + +/// Finds AI suggestions by type for a transcription +pub async fn find_by_type( + db: &DatabaseConnection, + transcription_id: Id, + item_type: AiSuggestionType, +) -> Result, Error> { + Ok(Entity::find() + .filter(entity::ai_suggested_items::Column::TranscriptionId.eq(transcription_id)) + .filter(entity::ai_suggested_items::Column::ItemType.eq(item_type)) + .all(db) + .await?) +} + +/// Deletes an AI suggested item by ID +pub async fn delete_by_id(db: &DatabaseConnection, id: Id) -> Result<(), Error> { + let model = find_by_id(db, id).await?; + Entity::delete_by_id(model.id).exec(db).await?; + Ok(()) +} diff --git a/entity_api/src/coaching_relationship.rs b/entity_api/src/coaching_relationship.rs index 91c36f9f..5b088728 100644 --- a/entity_api/src/coaching_relationship.rs +++ b/entity_api/src/coaching_relationship.rs @@ -89,6 +89,8 @@ pub async fn create( coach_last_name: coach.last_name, coachee_first_name: coachee.first_name, coachee_last_name: coachee.last_name, + meeting_url: inserted.meeting_url, + ai_privacy_level: inserted.ai_privacy_level, created_at: inserted.created_at, updated_at: inserted.updated_at, }) @@ -148,6 +150,8 @@ pub async fn find_by_organization_with_user_names( .column(coaching_relationships::Column::OrganizationId) .column(coaching_relationships::Column::CoachId) .column(coaching_relationships::Column::CoacheeId) + .column(coaching_relationships::Column::MeetingUrl) + .column(coaching_relationships::Column::AiPrivacyLevel) .column(coaching_relationships::Column::CreatedAt) .column(coaching_relationships::Column::UpdatedAt) .column_as(Expr::cust("coaches.first_name"), "coach_first_name") @@ -189,6 +193,8 @@ pub async fn find_by_user_and_organization_with_user_names( .column(coaching_relationships::Column::OrganizationId) .column(coaching_relationships::Column::CoachId) .column(coaching_relationships::Column::CoacheeId) + .column(coaching_relationships::Column::MeetingUrl) + .column(coaching_relationships::Column::AiPrivacyLevel) .column(coaching_relationships::Column::CreatedAt) .column(coaching_relationships::Column::UpdatedAt) .column_as(Expr::cust("coaches.first_name"), "coach_first_name") @@ -224,6 +230,8 @@ pub async fn get_relationship_with_user_names( .column(coaching_relationships::Column::OrganizationId) .column(coaching_relationships::Column::CoachId) .column(coaching_relationships::Column::CoacheeId) + .column(coaching_relationships::Column::MeetingUrl) + .column(coaching_relationships::Column::AiPrivacyLevel) .column(coaching_relationships::Column::CreatedAt) .column(coaching_relationships::Column::UpdatedAt) .column_as(Expr::cust("coaches.first_name"), "coach_first_name") @@ -235,6 +243,39 @@ pub async fn get_relationship_with_user_names( Ok(query.one(db).await?) } +/// Updates an existing coaching relationship +pub async fn update( + db: &DatabaseConnection, + id: Id, + meeting_url: Option, + ai_privacy_level: Option, +) -> Result { + let existing = find_by_id(db, id).await?; + + let now = Utc::now(); + + let mut active_model: ActiveModel = existing.into(); + active_model.updated_at = Set(now.into()); + + if let Some(url) = meeting_url { + active_model.meeting_url = Set(Some(url)); + } + + if let Some(level) = ai_privacy_level { + active_model.ai_privacy_level = Set(level); + } + + let _updated = active_model.update(db).await?; + + // Return the full relationship with user names + get_relationship_with_user_names(db, id) + .await? + .ok_or_else(|| Error { + source: None, + error_kind: EntityApiErrorKind::RecordNotFound, + }) +} + pub async fn by_coaching_relationship( query: Select, id: Id, @@ -287,6 +328,8 @@ pub struct CoachingRelationshipWithUserNames { pub coach_last_name: String, pub coachee_first_name: String, pub coachee_last_name: String, + pub meeting_url: Option, + pub ai_privacy_level: entity::ai_privacy_level::AiPrivacyLevel, pub created_at: DateTimeWithTimeZone, pub updated_at: DateTimeWithTimeZone, } @@ -298,7 +341,7 @@ impl Serialize for CoachingRelationshipWithUserNames { where S: Serializer, { - let mut state = serializer.serialize_struct("CoachingRelationship", 7)?; + let mut state = serializer.serialize_struct("CoachingRelationship", 11)?; state.serialize_field("id", &self.id)?; state.serialize_field("coach_id", &self.coach_id)?; state.serialize_field("coachee_id", &self.coachee_id)?; @@ -306,6 +349,8 @@ impl Serialize for CoachingRelationshipWithUserNames { state.serialize_field("coach_last_name", &self.coach_last_name)?; state.serialize_field("coachee_first_name", &self.coachee_first_name)?; state.serialize_field("coachee_last_name", &self.coachee_last_name)?; + state.serialize_field("meeting_url", &self.meeting_url)?; + state.serialize_field("ai_privacy_level", &self.ai_privacy_level)?; state.serialize_field("created_at", &self.created_at)?; state.serialize_field("updated_at", &self.updated_at)?; state.end() diff --git a/entity_api/src/lib.rs b/entity_api/src/lib.rs index b9e1bdeb..0b116429 100644 --- a/entity_api/src/lib.rs +++ b/entity_api/src/lib.rs @@ -7,6 +7,13 @@ pub use entity::{ organizations, overarching_goals, status, user_roles, users, users::Role, Id, }; +// AI Meeting Integration entity re-exports +pub use entity::{ + ai_privacy_level, ai_suggested_items, ai_suggestion, meeting_recording_status, + meeting_recordings, sentiment, transcript_segments, transcription_status, transcriptions, + user_integrations, +}; + pub mod action; pub mod agreement; pub mod coaching_relationship; @@ -20,6 +27,13 @@ pub mod query; pub mod user; pub mod user_role; +// AI Meeting Integration modules +pub mod ai_suggested_item; +pub mod meeting_recording; +pub mod transcript_segment; +pub mod transcription; +pub mod user_integration; + pub(crate) fn uuid_parse_str(uuid_str: &str) -> Result { Id::parse_str(uuid_str).map_err(|_| error::Error { source: None, @@ -124,6 +138,8 @@ pub async fn seed_database(db: &DatabaseConnection) { coachee_id: Set(caleb_bourg.id.clone().unwrap()), organization_id: Set(refactor_coaching_id), slug: Set("jim-caleb".to_owned()), + meeting_url: Set(None), + ai_privacy_level: Set(ai_privacy_level::AiPrivacyLevel::Full), created_at: Set(now.into()), updated_at: Set(now.into()), ..Default::default() @@ -138,6 +154,8 @@ pub async fn seed_database(db: &DatabaseConnection) { coachee_id: Set(jim_hodapp.id.clone().unwrap()), organization_id: Set(acme_corp.id.clone().unwrap()), slug: Set("jim-caleb".to_owned()), + meeting_url: Set(None), + ai_privacy_level: Set(ai_privacy_level::AiPrivacyLevel::Full), created_at: Set(now.into()), updated_at: Set(now.into()), ..Default::default() @@ -151,6 +169,8 @@ pub async fn seed_database(db: &DatabaseConnection) { coachee_id: Set(other_user.id.clone().unwrap()), organization_id: Set(acme_corp.id.clone().unwrap()), slug: Set("jim-other".to_owned()), + meeting_url: Set(None), + ai_privacy_level: Set(ai_privacy_level::AiPrivacyLevel::Full), created_at: Set(now.into()), updated_at: Set(now.into()), ..Default::default() diff --git a/entity_api/src/meeting_recording.rs b/entity_api/src/meeting_recording.rs new file mode 100644 index 00000000..6ed0560b --- /dev/null +++ b/entity_api/src/meeting_recording.rs @@ -0,0 +1,148 @@ +//! CRUD operations for meeting_recordings table. + +use super::error::{EntityApiErrorKind, Error}; +use entity::meeting_recording_status::MeetingRecordingStatus; +use entity::meeting_recordings::{ActiveModel, Entity, Model}; +use entity::Id; +use log::*; +use sea_orm::{ + entity::prelude::*, + ActiveValue::{Set, Unchanged}, + DatabaseConnection, QueryOrder, TryIntoModel, +}; + +/// Creates a new meeting recording record +pub async fn create(db: &DatabaseConnection, coaching_session_id: Id) -> Result { + debug!("Creating new meeting recording for session: {coaching_session_id}"); + + let now = chrono::Utc::now(); + + let active_model = ActiveModel { + coaching_session_id: Set(coaching_session_id), + status: Set(MeetingRecordingStatus::Pending), + created_at: Set(now.into()), + updated_at: Set(now.into()), + ..Default::default() + }; + + Ok(active_model.save(db).await?.try_into_model()?) +} + +/// Updates an existing meeting recording record +pub async fn update(db: &DatabaseConnection, id: Id, model: Model) -> Result { + let result = Entity::find_by_id(id).one(db).await?; + + match result { + Some(existing) => { + debug!("Updating meeting recording: {id}"); + + let active_model = ActiveModel { + id: Unchanged(existing.id), + coaching_session_id: Unchanged(existing.coaching_session_id), + recall_bot_id: Set(model.recall_bot_id), + status: Set(model.status), + recording_url: Set(model.recording_url), + duration_seconds: Set(model.duration_seconds), + started_at: Set(model.started_at), + ended_at: Set(model.ended_at), + error_message: Set(model.error_message), + created_at: Unchanged(existing.created_at), + updated_at: Set(chrono::Utc::now().into()), + }; + + Ok(active_model.update(db).await?.try_into_model()?) + } + None => { + debug!("Meeting recording with id {id} not found"); + Err(Error { + source: None, + error_kind: EntityApiErrorKind::RecordNotFound, + }) + } + } +} + +/// Updates just the status of a meeting recording +pub async fn update_status( + db: &DatabaseConnection, + id: Id, + status: MeetingRecordingStatus, + error_message: Option, +) -> Result { + let result = Entity::find_by_id(id).one(db).await?; + + match result { + Some(existing) => { + debug!("Updating meeting recording status to {:?}: {id}", status); + + let active_model = ActiveModel { + id: Unchanged(existing.id), + coaching_session_id: Unchanged(existing.coaching_session_id), + recall_bot_id: Unchanged(existing.recall_bot_id), + status: Set(status), + recording_url: Unchanged(existing.recording_url), + duration_seconds: Unchanged(existing.duration_seconds), + started_at: Unchanged(existing.started_at), + ended_at: Unchanged(existing.ended_at), + error_message: Set(error_message), + created_at: Unchanged(existing.created_at), + updated_at: Set(chrono::Utc::now().into()), + }; + + Ok(active_model.update(db).await?.try_into_model()?) + } + None => Err(Error { + source: None, + error_kind: EntityApiErrorKind::RecordNotFound, + }), + } +} + +/// Finds a meeting recording by ID +pub async fn find_by_id(db: &DatabaseConnection, id: Id) -> Result { + Entity::find_by_id(id).one(db).await?.ok_or_else(|| Error { + source: None, + error_kind: EntityApiErrorKind::RecordNotFound, + }) +} + +/// Finds a meeting recording by coaching session ID +pub async fn find_by_coaching_session_id( + db: &DatabaseConnection, + coaching_session_id: Id, +) -> Result, Error> { + Ok(Entity::find() + .filter(entity::meeting_recordings::Column::CoachingSessionId.eq(coaching_session_id)) + .one(db) + .await?) +} + +/// Finds the latest meeting recording for a coaching session +pub async fn find_latest_by_coaching_session_id( + db: &DatabaseConnection, + coaching_session_id: Id, +) -> Result, Error> { + Ok(Entity::find() + .filter(entity::meeting_recordings::Column::CoachingSessionId.eq(coaching_session_id)) + .order_by_desc(entity::meeting_recordings::Column::CreatedAt) + .one(db) + .await?) +} + +/// Finds a meeting recording by Recall.ai bot ID +pub async fn find_by_recall_bot_id( + db: &DatabaseConnection, + recall_bot_id: &str, +) -> Result, Error> { + Ok(Entity::find() + .filter(entity::meeting_recordings::Column::RecallBotId.eq(recall_bot_id)) + .one(db) + .await?) +} + +/// Deletes a meeting recording by ID +pub async fn delete_by_id(db: &DatabaseConnection, id: Id) -> Result<(), Error> { + let model = find_by_id(db, id).await?; + Entity::delete_by_id(model.id).exec(db).await?; + Ok(()) +} diff --git a/entity_api/src/transcript_segment.rs b/entity_api/src/transcript_segment.rs new file mode 100644 index 00000000..8bee5f9b --- /dev/null +++ b/entity_api/src/transcript_segment.rs @@ -0,0 +1,151 @@ +//! CRUD operations for transcript_segments table. + +use super::error::{EntityApiErrorKind, Error}; +use entity::sentiment::Sentiment; +use entity::transcript_segments::{ActiveModel, Entity, Model}; +use entity::Id; +use log::*; +use sea_orm::{entity::prelude::*, ActiveValue::Set, DatabaseConnection, QueryOrder, TryIntoModel}; + +/// Input for creating a transcript segment +#[derive(Debug, Clone)] +pub struct SegmentInput { + pub speaker_label: String, + pub text: String, + pub start_time_ms: i64, + pub end_time_ms: i64, + pub confidence: Option, + pub sentiment: Option, +} + +/// Creates a new transcript segment +pub async fn create( + db: &DatabaseConnection, + transcription_id: Id, + input: SegmentInput, +) -> Result { + debug!( + "Creating transcript segment for transcription: {transcription_id}, speaker: {}", + input.speaker_label + ); + + let now = chrono::Utc::now(); + + let active_model = ActiveModel { + transcription_id: Set(transcription_id), + speaker_label: Set(input.speaker_label), + speaker_user_id: Set(None), + text: Set(input.text), + start_time_ms: Set(input.start_time_ms), + end_time_ms: Set(input.end_time_ms), + confidence: Set(input.confidence), + sentiment: Set(input.sentiment), + created_at: Set(now.into()), + ..Default::default() + }; + + Ok(active_model.save(db).await?.try_into_model()?) +} + +/// Creates multiple transcript segments in batch +pub async fn create_batch( + db: &DatabaseConnection, + transcription_id: Id, + segments: Vec, +) -> Result, Error> { + debug!( + "Creating {} transcript segments for transcription: {transcription_id}", + segments.len() + ); + + let now = chrono::Utc::now(); + + let mut created = Vec::with_capacity(segments.len()); + + for segment in segments { + let active_model = ActiveModel { + transcription_id: Set(transcription_id), + speaker_label: Set(segment.speaker_label), + speaker_user_id: Set(None), + text: Set(segment.text), + start_time_ms: Set(segment.start_time_ms), + end_time_ms: Set(segment.end_time_ms), + confidence: Set(segment.confidence), + sentiment: Set(segment.sentiment), + created_at: Set(now.into()), + ..Default::default() + }; + + let model = active_model.save(db).await?.try_into_model()?; + created.push(model); + } + + Ok(created) +} + +/// Finds a transcript segment by ID +pub async fn find_by_id(db: &DatabaseConnection, id: Id) -> Result { + Entity::find_by_id(id).one(db).await?.ok_or_else(|| Error { + source: None, + error_kind: EntityApiErrorKind::RecordNotFound, + }) +} + +/// Finds all transcript segments for a transcription, ordered by start time +pub async fn find_by_transcription_id( + db: &DatabaseConnection, + transcription_id: Id, +) -> Result, Error> { + Ok(Entity::find() + .filter(entity::transcript_segments::Column::TranscriptionId.eq(transcription_id)) + .order_by_asc(entity::transcript_segments::Column::StartTimeMs) + .all(db) + .await?) +} + +/// Updates the speaker user ID for a segment (for speaker identification) +pub async fn update_speaker_user_id( + db: &DatabaseConnection, + id: Id, + speaker_user_id: Option, +) -> Result { + let result = Entity::find_by_id(id).one(db).await?; + + match result { + Some(existing) => { + debug!("Updating speaker user ID for segment: {id}"); + + let active_model = ActiveModel { + id: Set(existing.id), + transcription_id: Set(existing.transcription_id), + speaker_label: Set(existing.speaker_label), + speaker_user_id: Set(speaker_user_id), + text: Set(existing.text), + start_time_ms: Set(existing.start_time_ms), + end_time_ms: Set(existing.end_time_ms), + confidence: Set(existing.confidence), + sentiment: Set(existing.sentiment), + created_at: Set(existing.created_at), + }; + + Ok(active_model.update(db).await?.try_into_model()?) + } + None => Err(Error { + source: None, + error_kind: EntityApiErrorKind::RecordNotFound, + }), + } +} + +/// Deletes all transcript segments for a transcription +pub async fn delete_by_transcription_id( + db: &DatabaseConnection, + transcription_id: Id, +) -> Result { + let result = Entity::delete_many() + .filter(entity::transcript_segments::Column::TranscriptionId.eq(transcription_id)) + .exec(db) + .await?; + + Ok(result.rows_affected) +} diff --git a/entity_api/src/transcription.rs b/entity_api/src/transcription.rs new file mode 100644 index 00000000..81ef76da --- /dev/null +++ b/entity_api/src/transcription.rs @@ -0,0 +1,138 @@ +//! CRUD operations for transcriptions table. + +use super::error::{EntityApiErrorKind, Error}; +use entity::transcription_status::TranscriptionStatus; +use entity::transcriptions::{ActiveModel, Entity, Model}; +use entity::Id; +use log::*; +use sea_orm::{ + entity::prelude::*, + ActiveValue::{Set, Unchanged}, + DatabaseConnection, TryIntoModel, +}; + +/// Creates a new transcription record +pub async fn create(db: &DatabaseConnection, meeting_recording_id: Id) -> Result { + debug!("Creating new transcription for recording: {meeting_recording_id}"); + + let now = chrono::Utc::now(); + + let active_model = ActiveModel { + meeting_recording_id: Set(meeting_recording_id), + status: Set(TranscriptionStatus::Pending), + created_at: Set(now.into()), + updated_at: Set(now.into()), + ..Default::default() + }; + + Ok(active_model.save(db).await?.try_into_model()?) +} + +/// Updates an existing transcription record +pub async fn update(db: &DatabaseConnection, id: Id, model: Model) -> Result { + let result = Entity::find_by_id(id).one(db).await?; + + match result { + Some(existing) => { + debug!("Updating transcription: {id}"); + + let active_model = ActiveModel { + id: Unchanged(existing.id), + meeting_recording_id: Unchanged(existing.meeting_recording_id), + assemblyai_transcript_id: Set(model.assemblyai_transcript_id), + status: Set(model.status), + full_text: Set(model.full_text), + summary: Set(model.summary), + confidence_score: Set(model.confidence_score), + word_count: Set(model.word_count), + language_code: Set(model.language_code), + error_message: Set(model.error_message), + created_at: Unchanged(existing.created_at), + updated_at: Set(chrono::Utc::now().into()), + }; + + Ok(active_model.update(db).await?.try_into_model()?) + } + None => { + debug!("Transcription with id {id} not found"); + Err(Error { + source: None, + error_kind: EntityApiErrorKind::RecordNotFound, + }) + } + } +} + +/// Updates the status of a transcription +pub async fn update_status( + db: &DatabaseConnection, + id: Id, + status: TranscriptionStatus, + error_message: Option, +) -> Result { + let result = Entity::find_by_id(id).one(db).await?; + + match result { + Some(existing) => { + debug!("Updating transcription status to {:?}: {id}", status); + + let active_model = ActiveModel { + id: Unchanged(existing.id), + meeting_recording_id: Unchanged(existing.meeting_recording_id), + assemblyai_transcript_id: Unchanged(existing.assemblyai_transcript_id), + status: Set(status), + full_text: Unchanged(existing.full_text), + summary: Unchanged(existing.summary), + confidence_score: Unchanged(existing.confidence_score), + word_count: Unchanged(existing.word_count), + language_code: Unchanged(existing.language_code), + error_message: Set(error_message), + created_at: Unchanged(existing.created_at), + updated_at: Set(chrono::Utc::now().into()), + }; + + Ok(active_model.update(db).await?.try_into_model()?) + } + None => Err(Error { + source: None, + error_kind: EntityApiErrorKind::RecordNotFound, + }), + } +} + +/// Finds a transcription by ID +pub async fn find_by_id(db: &DatabaseConnection, id: Id) -> Result { + Entity::find_by_id(id).one(db).await?.ok_or_else(|| Error { + source: None, + error_kind: EntityApiErrorKind::RecordNotFound, + }) +} + +/// Finds a transcription by meeting recording ID +pub async fn find_by_meeting_recording_id( + db: &DatabaseConnection, + meeting_recording_id: Id, +) -> Result, Error> { + Ok(Entity::find() + .filter(entity::transcriptions::Column::MeetingRecordingId.eq(meeting_recording_id)) + .one(db) + .await?) +} + +/// Finds a transcription by AssemblyAI transcript ID +pub async fn find_by_assemblyai_id( + db: &DatabaseConnection, + assemblyai_id: &str, +) -> Result, Error> { + Ok(Entity::find() + .filter(entity::transcriptions::Column::AssemblyaiTranscriptId.eq(assemblyai_id)) + .one(db) + .await?) +} + +/// Deletes a transcription by ID +pub async fn delete_by_id(db: &DatabaseConnection, id: Id) -> Result<(), Error> { + let model = find_by_id(db, id).await?; + Entity::delete_by_id(model.id).exec(db).await?; + Ok(()) +} diff --git a/entity_api/src/user_integration.rs b/entity_api/src/user_integration.rs new file mode 100644 index 00000000..efd105cc --- /dev/null +++ b/entity_api/src/user_integration.rs @@ -0,0 +1,112 @@ +//! CRUD operations for user_integrations table. + +use super::error::{EntityApiErrorKind, Error}; +use entity::user_integrations::{ActiveModel, Entity, Model}; +use entity::Id; +use log::*; +use sea_orm::{ + entity::prelude::*, + ActiveValue::{Set, Unchanged}, + DatabaseConnection, TryIntoModel, +}; + +/// Creates a new user integration record +pub async fn create(db: &DatabaseConnection, user_id: Id) -> Result { + debug!("Creating new user integration for user_id: {user_id}"); + + let now = chrono::Utc::now(); + + let active_model = ActiveModel { + user_id: Set(user_id), + created_at: Set(now.into()), + updated_at: Set(now.into()), + ..Default::default() + }; + + Ok(active_model.save(db).await?.try_into_model()?) +} + +/// Updates an existing user integration record +pub async fn update(db: &DatabaseConnection, id: Id, model: Model) -> Result { + let result = Entity::find_by_id(id).one(db).await?; + + match result { + Some(existing) => { + debug!("Updating user integration: {id}"); + + let active_model = ActiveModel { + id: Unchanged(existing.id), + user_id: Unchanged(existing.user_id), + google_access_token: Set(model.google_access_token), + google_refresh_token: Set(model.google_refresh_token), + google_token_expiry: Set(model.google_token_expiry), + google_email: Set(model.google_email), + recall_ai_api_key: Set(model.recall_ai_api_key), + recall_ai_region: Set(model.recall_ai_region), + recall_ai_verified_at: Set(model.recall_ai_verified_at), + assembly_ai_api_key: Set(model.assembly_ai_api_key), + assembly_ai_verified_at: Set(model.assembly_ai_verified_at), + created_at: Unchanged(existing.created_at), + updated_at: Set(chrono::Utc::now().into()), + }; + + Ok(active_model.update(db).await?.try_into_model()?) + } + None => { + debug!("User integration with id {id} not found"); + Err(Error { + source: None, + error_kind: EntityApiErrorKind::RecordNotFound, + }) + } + } +} + +/// Finds a user integration by ID +pub async fn find_by_id(db: &DatabaseConnection, id: Id) -> Result { + Entity::find_by_id(id).one(db).await?.ok_or_else(|| Error { + source: None, + error_kind: EntityApiErrorKind::RecordNotFound, + }) +} + +/// Finds a user integration by user ID +pub async fn find_by_user_id(db: &DatabaseConnection, user_id: Id) -> Result, Error> { + Ok(Entity::find() + .filter(entity::user_integrations::Column::UserId.eq(user_id)) + .one(db) + .await?) +} + +/// Gets or creates a user integration for a user +pub async fn get_or_create(db: &DatabaseConnection, user_id: Id) -> Result { + match find_by_user_id(db, user_id).await? { + Some(model) => Ok(model), + None => create(db, user_id).await, + } +} + +/// Deletes a user integration by ID +pub async fn delete_by_id(db: &DatabaseConnection, id: Id) -> Result<(), Error> { + let model = find_by_id(db, id).await?; + Entity::delete_by_id(model.id).exec(db).await?; + Ok(()) +} + +#[cfg(test)] +#[cfg(feature = "mock")] +mod tests { + use super::*; + use sea_orm::{DatabaseBackend, MockDatabase}; + + #[tokio::test] + async fn find_by_user_id_returns_none_when_not_found() -> Result<(), Error> { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results::, _>(vec![vec![]]) + .into_connection(); + + let result = find_by_user_id(&db, Id::new_v4()).await?; + assert!(result.is_none()); + Ok(()) + } +} diff --git a/migration/src/lib.rs b/migration/src/lib.rs index d87ce946..53022f90 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -12,6 +12,10 @@ mod m20251007_093603_add_user_roles_table_and_super_admin; mod m20251008_000000_migrate_admin_users_to_super_admin_role; mod m20251009_000000_migrate_regular_users_to_user_roles; mod m20251024_000000_remove_organizations_users_table; +mod m20251220_000001_add_user_integrations; +mod m20251220_000002_add_meeting_fields_to_relationships; +mod m20251220_000003_add_meeting_recording_tables; + pub struct Migrator; #[async_trait::async_trait] @@ -30,6 +34,9 @@ impl MigratorTrait for Migrator { Box::new(m20251008_000000_migrate_admin_users_to_super_admin_role::Migration), Box::new(m20251009_000000_migrate_regular_users_to_user_roles::Migration), Box::new(m20251024_000000_remove_organizations_users_table::Migration), + Box::new(m20251220_000001_add_user_integrations::Migration), + Box::new(m20251220_000002_add_meeting_fields_to_relationships::Migration), + Box::new(m20251220_000003_add_meeting_recording_tables::Migration), ] } } diff --git a/migration/src/m20251220_000001_add_user_integrations.rs b/migration/src/m20251220_000001_add_user_integrations.rs new file mode 100644 index 00000000..40e3dd2b --- /dev/null +++ b/migration/src/m20251220_000001_add_user_integrations.rs @@ -0,0 +1,69 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Create the user_integrations table for storing encrypted API credentials + // This table allows coaches to configure their Google OAuth, Recall.ai, and AssemblyAI credentials + let create_table_sql = r#" + CREATE TABLE IF NOT EXISTS refactor_platform.user_integrations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES refactor_platform.users(id) ON DELETE CASCADE, + + -- Google OAuth (encrypted) + google_access_token TEXT, + google_refresh_token TEXT, + google_token_expiry TIMESTAMPTZ, + google_email VARCHAR(255), + + -- Recall.ai (encrypted) + recall_ai_api_key TEXT, + recall_ai_region VARCHAR(50) DEFAULT 'us-west-2', + recall_ai_verified_at TIMESTAMPTZ, + + -- AssemblyAI (encrypted) + assembly_ai_api_key TEXT, + assembly_ai_verified_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT user_integrations_user_id_unique UNIQUE(user_id) + ) + "#; + + manager + .get_connection() + .execute_unprepared(create_table_sql) + .await?; + + // Set ownership to refactor user for proper permissions + manager + .get_connection() + .execute_unprepared("ALTER TABLE refactor_platform.user_integrations OWNER TO refactor") + .await?; + + // Create index for faster lookups by user_id + manager + .get_connection() + .execute_unprepared( + "CREATE INDEX IF NOT EXISTS idx_user_integrations_user_id + ON refactor_platform.user_integrations(user_id)", + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_unprepared("DROP TABLE IF EXISTS refactor_platform.user_integrations") + .await?; + + Ok(()) + } +} diff --git a/migration/src/m20251220_000002_add_meeting_fields_to_relationships.rs b/migration/src/m20251220_000002_add_meeting_fields_to_relationships.rs new file mode 100644 index 00000000..9ff254bd --- /dev/null +++ b/migration/src/m20251220_000002_add_meeting_fields_to_relationships.rs @@ -0,0 +1,63 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Create the ai_privacy_level enum for per-relationship privacy settings + // This allows coaches to configure AI features on a per-client basis: + // - 'none': No AI recording or transcribing (for clients uncomfortable with AI) + // - 'transcribe_only': Text transcription only, no video/audio storage + // - 'full': All AI recording and transcribing features enabled + manager + .get_connection() + .execute_unprepared( + "CREATE TYPE refactor_platform.ai_privacy_level AS ENUM ( + 'none', + 'transcribe_only', + 'full' + )", + ) + .await?; + + // Set ownership to refactor user + manager + .get_connection() + .execute_unprepared("ALTER TYPE refactor_platform.ai_privacy_level OWNER TO refactor") + .await?; + + // Add meeting_url and ai_privacy_level columns to coaching_relationships + manager + .get_connection() + .execute_unprepared( + "ALTER TABLE refactor_platform.coaching_relationships + ADD COLUMN IF NOT EXISTS meeting_url VARCHAR(500), + ADD COLUMN IF NOT EXISTS ai_privacy_level refactor_platform.ai_privacy_level NOT NULL DEFAULT 'full'", + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Remove the columns from coaching_relationships + manager + .get_connection() + .execute_unprepared( + "ALTER TABLE refactor_platform.coaching_relationships + DROP COLUMN IF EXISTS meeting_url, + DROP COLUMN IF EXISTS ai_privacy_level", + ) + .await?; + + // Drop the enum type + manager + .get_connection() + .execute_unprepared("DROP TYPE IF EXISTS refactor_platform.ai_privacy_level") + .await?; + + Ok(()) + } +} diff --git a/migration/src/m20251220_000003_add_meeting_recording_tables.rs b/migration/src/m20251220_000003_add_meeting_recording_tables.rs new file mode 100644 index 00000000..f2fb1286 --- /dev/null +++ b/migration/src/m20251220_000003_add_meeting_recording_tables.rs @@ -0,0 +1,316 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Create meeting_recording_status enum + manager + .get_connection() + .execute_unprepared( + "CREATE TYPE refactor_platform.meeting_recording_status AS ENUM ( + 'pending', + 'joining', + 'recording', + 'processing', + 'completed', + 'failed' + )", + ) + .await?; + + manager + .get_connection() + .execute_unprepared( + "ALTER TYPE refactor_platform.meeting_recording_status OWNER TO refactor", + ) + .await?; + + // Create transcription_status enum + manager + .get_connection() + .execute_unprepared( + "CREATE TYPE refactor_platform.transcription_status AS ENUM ( + 'pending', + 'processing', + 'completed', + 'failed' + )", + ) + .await?; + + manager + .get_connection() + .execute_unprepared( + "ALTER TYPE refactor_platform.transcription_status OWNER TO refactor", + ) + .await?; + + // Create sentiment enum for transcript segment analysis + manager + .get_connection() + .execute_unprepared( + "CREATE TYPE refactor_platform.sentiment AS ENUM ( + 'positive', + 'neutral', + 'negative' + )", + ) + .await?; + + manager + .get_connection() + .execute_unprepared("ALTER TYPE refactor_platform.sentiment OWNER TO refactor") + .await?; + + // Create ai_suggestion_type enum + manager + .get_connection() + .execute_unprepared( + "CREATE TYPE refactor_platform.ai_suggestion_type AS ENUM ( + 'action', + 'agreement' + )", + ) + .await?; + + manager + .get_connection() + .execute_unprepared("ALTER TYPE refactor_platform.ai_suggestion_type OWNER TO refactor") + .await?; + + // Create ai_suggestion_status enum + manager + .get_connection() + .execute_unprepared( + "CREATE TYPE refactor_platform.ai_suggestion_status AS ENUM ( + 'pending', + 'accepted', + 'dismissed' + )", + ) + .await?; + + manager + .get_connection() + .execute_unprepared( + "ALTER TYPE refactor_platform.ai_suggestion_status OWNER TO refactor", + ) + .await?; + + // Create meeting_recordings table + let create_recordings_sql = r#" + CREATE TABLE IF NOT EXISTS refactor_platform.meeting_recordings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + coaching_session_id UUID NOT NULL + REFERENCES refactor_platform.coaching_sessions(id) ON DELETE CASCADE, + recall_bot_id VARCHAR(255), + status refactor_platform.meeting_recording_status NOT NULL DEFAULT 'pending', + recording_url TEXT, + duration_seconds INTEGER, + started_at TIMESTAMPTZ, + ended_at TIMESTAMPTZ, + error_message TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + "#; + + manager + .get_connection() + .execute_unprepared(create_recordings_sql) + .await?; + + manager + .get_connection() + .execute_unprepared( + "ALTER TABLE refactor_platform.meeting_recordings OWNER TO refactor", + ) + .await?; + + // Create transcriptions table + let create_transcriptions_sql = r#" + CREATE TABLE IF NOT EXISTS refactor_platform.transcriptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + meeting_recording_id UUID NOT NULL + REFERENCES refactor_platform.meeting_recordings(id) ON DELETE CASCADE, + assemblyai_transcript_id VARCHAR(255), + status refactor_platform.transcription_status NOT NULL DEFAULT 'pending', + full_text TEXT, + summary TEXT, + confidence_score DOUBLE PRECISION, + word_count INTEGER, + language_code VARCHAR(10) DEFAULT 'en', + error_message TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT transcriptions_meeting_recording_unique UNIQUE(meeting_recording_id) + ) + "#; + + manager + .get_connection() + .execute_unprepared(create_transcriptions_sql) + .await?; + + manager + .get_connection() + .execute_unprepared("ALTER TABLE refactor_platform.transcriptions OWNER TO refactor") + .await?; + + // Create transcript_segments table (utterances with speaker diarization) + let create_segments_sql = r#" + CREATE TABLE IF NOT EXISTS refactor_platform.transcript_segments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + transcription_id UUID NOT NULL + REFERENCES refactor_platform.transcriptions(id) ON DELETE CASCADE, + speaker_label VARCHAR(50) NOT NULL, + speaker_user_id UUID REFERENCES refactor_platform.users(id), + text TEXT NOT NULL, + start_time_ms BIGINT NOT NULL, + end_time_ms BIGINT NOT NULL, + confidence DOUBLE PRECISION, + sentiment refactor_platform.sentiment, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + "#; + + manager + .get_connection() + .execute_unprepared(create_segments_sql) + .await?; + + manager + .get_connection() + .execute_unprepared( + "ALTER TABLE refactor_platform.transcript_segments OWNER TO refactor", + ) + .await?; + + // Create ai_suggested_items table (before user approval) + let create_suggestions_sql = r#" + CREATE TABLE IF NOT EXISTS refactor_platform.ai_suggested_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + transcription_id UUID NOT NULL + REFERENCES refactor_platform.transcriptions(id) ON DELETE CASCADE, + item_type refactor_platform.ai_suggestion_type NOT NULL, + content TEXT NOT NULL, + source_text TEXT, + confidence DOUBLE PRECISION, + status refactor_platform.ai_suggestion_status NOT NULL DEFAULT 'pending', + accepted_entity_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + "#; + + manager + .get_connection() + .execute_unprepared(create_suggestions_sql) + .await?; + + manager + .get_connection() + .execute_unprepared( + "ALTER TABLE refactor_platform.ai_suggested_items OWNER TO refactor", + ) + .await?; + + // Create indexes for efficient querying + manager + .get_connection() + .execute_unprepared( + "CREATE INDEX IF NOT EXISTS idx_meeting_recordings_session + ON refactor_platform.meeting_recordings(coaching_session_id)", + ) + .await?; + + manager + .get_connection() + .execute_unprepared( + "CREATE INDEX IF NOT EXISTS idx_transcriptions_recording + ON refactor_platform.transcriptions(meeting_recording_id)", + ) + .await?; + + manager + .get_connection() + .execute_unprepared( + "CREATE INDEX IF NOT EXISTS idx_transcript_segments_transcription + ON refactor_platform.transcript_segments(transcription_id)", + ) + .await?; + + manager + .get_connection() + .execute_unprepared( + "CREATE INDEX IF NOT EXISTS idx_ai_suggested_items_transcription + ON refactor_platform.ai_suggested_items(transcription_id)", + ) + .await?; + + manager + .get_connection() + .execute_unprepared( + "CREATE INDEX IF NOT EXISTS idx_ai_suggested_items_status + ON refactor_platform.ai_suggested_items(status)", + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Drop tables in reverse order of creation (respecting foreign key dependencies) + manager + .get_connection() + .execute_unprepared("DROP TABLE IF EXISTS refactor_platform.ai_suggested_items") + .await?; + + manager + .get_connection() + .execute_unprepared("DROP TABLE IF EXISTS refactor_platform.transcript_segments") + .await?; + + manager + .get_connection() + .execute_unprepared("DROP TABLE IF EXISTS refactor_platform.transcriptions") + .await?; + + manager + .get_connection() + .execute_unprepared("DROP TABLE IF EXISTS refactor_platform.meeting_recordings") + .await?; + + // Drop enum types + manager + .get_connection() + .execute_unprepared("DROP TYPE IF EXISTS refactor_platform.ai_suggestion_status") + .await?; + + manager + .get_connection() + .execute_unprepared("DROP TYPE IF EXISTS refactor_platform.ai_suggestion_type") + .await?; + + manager + .get_connection() + .execute_unprepared("DROP TYPE IF EXISTS refactor_platform.sentiment") + .await?; + + manager + .get_connection() + .execute_unprepared("DROP TYPE IF EXISTS refactor_platform.transcription_status") + .await?; + + manager + .get_connection() + .execute_unprepared("DROP TYPE IF EXISTS refactor_platform.meeting_recording_status") + .await?; + + Ok(()) + } +} diff --git a/service/src/config.rs b/service/src/config.rs index b8861ef6..c61ec5da 100644 --- a/service/src/config.rs +++ b/service/src/config.rs @@ -146,6 +146,76 @@ pub struct Config { /// Session expiry duration in seconds (default: 24 hours = 86400 seconds) #[arg(long, env, default_value_t = 86400)] pub backend_session_expiry_seconds: u64, + + // AI Meeting Integration configuration + /// 32-byte AES encryption key for encrypting sensitive API keys in database (hex-encoded) + #[arg(long, env)] + encryption_key: Option, + + /// Platform-default Recall.ai API key (optional, users can configure their own) + #[arg(long, env)] + recall_ai_api_key: Option, + + /// Recall.ai region (default: us-west-2) + #[arg(long, env, default_value = "us-west-2")] + recall_ai_region: Option, + + /// Platform-default AssemblyAI API key (optional, users can configure their own) + #[arg(long, env)] + assembly_ai_api_key: Option, + + /// Google OAuth client ID + #[arg(long, env)] + google_client_id: Option, + + /// Google OAuth client secret + #[arg(long, env)] + google_client_secret: Option, + + /// Google OAuth redirect URI + #[arg(long, env)] + google_redirect_uri: Option, + + /// Base URL for webhook endpoints (e.g., https://api.refactor.coach) + #[arg(long, env)] + webhook_base_url: Option, + + /// Secret for validating incoming webhooks + #[arg(long, env)] + webhook_secret: Option, + + // External API Base URLs (for testing/override) + /// AssemblyAI API base URL + #[arg(long, env, default_value = "https://api.assemblyai.com/v2")] + assembly_ai_base_url: String, + + /// Recall.ai base domain (region prefix will be added, e.g., "us-west-2.{domain}") + #[arg(long, env, default_value = "recall.ai")] + recall_ai_base_domain: String, + + /// Google OAuth authorization URL + #[arg( + long, + env, + default_value = "https://accounts.google.com/o/oauth2/v2/auth" + )] + google_oauth_auth_url: String, + + /// Google OAuth token URL + #[arg(long, env, default_value = "https://oauth2.googleapis.com/token")] + google_oauth_token_url: String, + + /// Google user info URL + #[arg( + long, + env, + default_value = "https://www.googleapis.com/oauth2/v2/userinfo" + )] + google_userinfo_url: String, + + /// Google Meet API base URL + #[arg(long, env, default_value = "https://meet.googleapis.com/v2")] + google_meet_api_url: String, } impl Default for Config { @@ -210,6 +280,75 @@ impl Config { // This could check an environment variable, or a config field self.runtime_env() == RustEnv::Production } + + // AI Meeting Integration accessors + + pub fn encryption_key(&self) -> Option { + self.encryption_key.clone() + } + + pub fn recall_ai_api_key(&self) -> Option { + self.recall_ai_api_key.clone() + } + + pub fn recall_ai_region(&self) -> Option { + self.recall_ai_region.clone() + } + + pub fn assembly_ai_api_key(&self) -> Option { + self.assembly_ai_api_key.clone() + } + + pub fn google_client_id(&self) -> Option { + self.google_client_id.clone() + } + + pub fn google_client_secret(&self) -> Option { + self.google_client_secret.clone() + } + + pub fn google_redirect_uri(&self) -> Option { + self.google_redirect_uri.clone() + } + + pub fn webhook_base_url(&self) -> Option { + self.webhook_base_url.clone() + } + + pub fn webhook_secret(&self) -> Option { + self.webhook_secret.clone() + } + + // External API Base URL accessors + + pub fn assembly_ai_base_url(&self) -> &str { + &self.assembly_ai_base_url + } + + pub fn recall_ai_base_domain(&self) -> &str { + &self.recall_ai_base_domain + } + + /// Constructs the full Recall.ai API URL for a given region + pub fn recall_ai_url_for_region(&self, region: &str) -> String { + format!("https://{}.{}", region, self.recall_ai_base_domain) + } + + pub fn google_oauth_auth_url(&self) -> &str { + &self.google_oauth_auth_url + } + + pub fn google_oauth_token_url(&self) -> &str { + &self.google_oauth_token_url + } + + pub fn google_userinfo_url(&self) -> &str { + &self.google_userinfo_url + } + + pub fn google_meet_api_url(&self) -> &str { + &self.google_meet_api_url + } } impl ApiVersion { diff --git a/web/src/controller/coaching_relationship_controller.rs b/web/src/controller/coaching_relationship_controller.rs new file mode 100644 index 00000000..35039380 --- /dev/null +++ b/web/src/controller/coaching_relationship_controller.rs @@ -0,0 +1,81 @@ +//! Controller for coaching relationship operations. +//! +//! Handles operations on coaching relationships that are not nested under organizations, +//! such as updating meeting URLs and AI privacy levels. + +use crate::controller::ApiResponse; +use crate::extractors::authenticated_user::AuthenticatedUser; +use crate::extractors::compare_api_version::CompareApiVersion; +use crate::params::coaching_relationship::UpdateParams; +use crate::{AppState, Error}; + +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::Json; + +use domain::coaching_relationship as CoachingRelationshipApi; +use domain::Id; +use log::*; +use service::config::ApiVersion; + +/// UPDATE a CoachingRelationship. +/// +/// Updates the meeting URL and/or AI privacy level for a coaching relationship. +/// Only the coach can update these settings. +#[utoipa::path( + put, + path = "/coaching_relationships/{id}", + params( + ApiVersion, + ("id" = Id, Path, description = "Coaching relationship ID"), + ), + request_body = UpdateParams, + responses( + (status = 200, description = "Successfully updated the coaching relationship", body = CoachingRelationshipWithUserNames), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Coaching relationship not found"), + ), + security( + ("cookie_auth" = []) + ) +)] +pub async fn update( + CompareApiVersion(_v): CompareApiVersion, + AuthenticatedUser(user): AuthenticatedUser, + State(app_state): State, + Path(id): Path, + Json(params): Json, +) -> Result { + debug!("UPDATE CoachingRelationship {id} with params: {params:?}"); + + // First, verify the relationship exists and user is the coach + let relationship = CoachingRelationshipApi::find_by_id(app_state.db_conn_ref(), id).await?; + + if relationship.coach_id != user.id { + warn!( + "User {} attempted to update coaching relationship {} but is not the coach", + user.id, id + ); + return Err(Error::Domain(domain::error::Error { + source: None, + error_kind: domain::error::DomainErrorKind::Internal( + domain::error::InternalErrorKind::Entity( + domain::error::EntityErrorKind::Unauthenticated, + ), + ), + })); + } + + let updated = CoachingRelationshipApi::update( + app_state.db_conn_ref(), + id, + params.meeting_url, + params.ai_privacy_level, + ) + .await?; + + debug!("Updated CoachingRelationship: {updated:?}"); + + Ok(Json(ApiResponse::new(StatusCode::OK.into(), updated))) +} diff --git a/web/src/controller/integration_controller.rs b/web/src/controller/integration_controller.rs new file mode 100644 index 00000000..89a65320 --- /dev/null +++ b/web/src/controller/integration_controller.rs @@ -0,0 +1,281 @@ +//! Controller for user integration management. +//! +//! Handles API key storage and verification for external services +//! (Recall.ai, AssemblyAI). Google OAuth is handled separately. + +use crate::controller::ApiResponse; +use crate::extractors::authenticated_user::AuthenticatedUser; +use crate::extractors::compare_api_version::CompareApiVersion; +use crate::params::integration::{ + IntegrationStatusResponse, UpdateIntegrationParams, VerifyApiKeyResponse, +}; +use crate::{AppState, Error}; + +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::Json; + +use domain::gateway::assembly_ai::AssemblyAiClient; +use domain::gateway::recall_ai::RecallAiClient; +use domain::user_integrations::Model as UserIntegrationModel; +use domain::{user_integration, Id}; +use service::config::ApiVersion; + +/// GET user integration status +/// +/// Returns the integration configuration status for a user without exposing API keys. +#[utoipa::path( + get, + path = "/users/{user_id}/integrations", + params( + ApiVersion, + ("user_id" = Id, Path, description = "User ID"), + ), + responses( + (status = 200, description = "Integration status retrieved", body = IntegrationStatusResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "User not found"), + ), + security( + ("cookie_auth" = []) + ) +)] +pub async fn read( + CompareApiVersion(_v): CompareApiVersion, + AuthenticatedUser(_user): AuthenticatedUser, + State(app_state): State, + Path(user_id): Path, +) -> Result { + let integration: UserIntegrationModel = + user_integration::get_or_create(app_state.db_conn_ref(), user_id).await?; + let response: IntegrationStatusResponse = integration.into(); + Ok(Json(ApiResponse::new(StatusCode::OK.into(), response))) +} + +/// PUT update user integrations +/// +/// Updates API keys for external services. Keys are encrypted at rest. +#[utoipa::path( + put, + path = "/users/{user_id}/integrations", + params( + ApiVersion, + ("user_id" = Id, Path, description = "User ID"), + ), + request_body = UpdateIntegrationParams, + responses( + (status = 200, description = "Integration updated", body = IntegrationStatusResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "User not found"), + ), + security( + ("cookie_auth" = []) + ) +)] +pub async fn update( + CompareApiVersion(_v): CompareApiVersion, + AuthenticatedUser(_user): AuthenticatedUser, + State(app_state): State, + Path(user_id): Path, + Json(params): Json, +) -> Result { + let mut integration: UserIntegrationModel = + user_integration::get_or_create(app_state.db_conn_ref(), user_id).await?; + + // Update only provided fields + if let Some(key) = params.recall_ai_api_key { + integration.recall_ai_api_key = Some(key); + // Reset verification status when key changes + integration.recall_ai_verified_at = None; + } + if let Some(region) = params.recall_ai_region { + integration.recall_ai_region = Some(region); + } + if let Some(key) = params.assembly_ai_api_key { + integration.assembly_ai_api_key = Some(key); + // Reset verification status when key changes + integration.assembly_ai_verified_at = None; + } + + let updated: UserIntegrationModel = + user_integration::update(app_state.db_conn_ref(), integration.id, integration).await?; + let response: IntegrationStatusResponse = updated.into(); + Ok(Json(ApiResponse::new(StatusCode::OK.into(), response))) +} + +/// POST verify Recall.ai API key +/// +/// Tests if the configured Recall.ai API key is valid. +#[utoipa::path( + post, + path = "/users/{user_id}/integrations/verify/recall-ai", + params( + ApiVersion, + ("user_id" = Id, Path, description = "User ID"), + ), + responses( + (status = 200, description = "Verification result", body = VerifyApiKeyResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "User not found"), + ), + security( + ("cookie_auth" = []) + ) +)] +pub async fn verify_recall_ai( + CompareApiVersion(_v): CompareApiVersion, + AuthenticatedUser(_user): AuthenticatedUser, + State(app_state): State, + Path(user_id): Path, +) -> Result { + let mut integration: UserIntegrationModel = + user_integration::get_or_create(app_state.db_conn_ref(), user_id).await?; + + let api_key = match &integration.recall_ai_api_key { + Some(key) => key, + None => { + return Ok(Json(ApiResponse::new( + StatusCode::OK.into(), + VerifyApiKeyResponse { + valid: false, + message: Some("No Recall.ai API key configured".to_string()), + }, + ))); + } + }; + + let region = integration + .recall_ai_region + .as_deref() + .and_then(|s| s.parse().ok()) + .unwrap_or_default(); + + let config = &app_state.config; + let client = RecallAiClient::new(api_key, region, config.recall_ai_base_domain())?; + let valid = client.verify_api_key().await?; + + if valid { + // Update verification timestamp + integration.recall_ai_verified_at = Some(chrono::Utc::now().into()); + let _: UserIntegrationModel = + user_integration::update(app_state.db_conn_ref(), integration.id, integration).await?; + } + + Ok(Json(ApiResponse::new( + StatusCode::OK.into(), + VerifyApiKeyResponse { + valid, + message: if valid { + Some("API key verified successfully".to_string()) + } else { + Some("API key is invalid".to_string()) + }, + }, + ))) +} + +/// POST verify AssemblyAI API key +/// +/// Tests if the configured AssemblyAI API key is valid. +#[utoipa::path( + post, + path = "/users/{user_id}/integrations/verify/assembly-ai", + params( + ApiVersion, + ("user_id" = Id, Path, description = "User ID"), + ), + responses( + (status = 200, description = "Verification result", body = VerifyApiKeyResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "User not found"), + ), + security( + ("cookie_auth" = []) + ) +)] +pub async fn verify_assembly_ai( + CompareApiVersion(_v): CompareApiVersion, + AuthenticatedUser(_user): AuthenticatedUser, + State(app_state): State, + Path(user_id): Path, +) -> Result { + let mut integration: UserIntegrationModel = + user_integration::get_or_create(app_state.db_conn_ref(), user_id).await?; + + let api_key = match &integration.assembly_ai_api_key { + Some(key) => key, + None => { + return Ok(Json(ApiResponse::new( + StatusCode::OK.into(), + VerifyApiKeyResponse { + valid: false, + message: Some("No AssemblyAI API key configured".to_string()), + }, + ))); + } + }; + + let config = &app_state.config; + let client = AssemblyAiClient::new(api_key, config.assembly_ai_base_url())?; + let valid = client.verify_api_key().await?; + + if valid { + // Update verification timestamp + integration.assembly_ai_verified_at = Some(chrono::Utc::now().into()); + let _: UserIntegrationModel = + user_integration::update(app_state.db_conn_ref(), integration.id, integration).await?; + } + + Ok(Json(ApiResponse::new( + StatusCode::OK.into(), + VerifyApiKeyResponse { + valid, + message: if valid { + Some("API key verified successfully".to_string()) + } else { + Some("API key is invalid".to_string()) + }, + }, + ))) +} + +/// DELETE disconnect Google account +/// +/// Removes Google OAuth tokens from user's integration. +#[utoipa::path( + delete, + path = "/users/{user_id}/integrations/google", + params( + ApiVersion, + ("user_id" = Id, Path, description = "User ID"), + ), + responses( + (status = 200, description = "Google account disconnected", body = IntegrationStatusResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "User not found"), + ), + security( + ("cookie_auth" = []) + ) +)] +pub async fn disconnect_google( + CompareApiVersion(_v): CompareApiVersion, + AuthenticatedUser(_user): AuthenticatedUser, + State(app_state): State, + Path(user_id): Path, +) -> Result { + let mut integration: UserIntegrationModel = + user_integration::get_or_create(app_state.db_conn_ref(), user_id).await?; + + // Clear Google OAuth fields + integration.google_access_token = None; + integration.google_refresh_token = None; + integration.google_token_expiry = None; + integration.google_email = None; + + let updated: UserIntegrationModel = + user_integration::update(app_state.db_conn_ref(), integration.id, integration).await?; + let response: IntegrationStatusResponse = updated.into(); + Ok(Json(ApiResponse::new(StatusCode::OK.into(), response))) +} diff --git a/web/src/controller/meeting_recording_controller.rs b/web/src/controller/meeting_recording_controller.rs new file mode 100644 index 00000000..e6cbd36d --- /dev/null +++ b/web/src/controller/meeting_recording_controller.rs @@ -0,0 +1,581 @@ +//! 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 db = app_state.db_conn_ref(); + let config = &app_state.config; + + let recording: Option = + MeetingRecordingApi::find_latest_by_coaching_session_id(db, session_id).await?; + + match recording { + Some(rec) => { + // If recording is in a transitional state and has a bot ID, poll Recall.ai for updates + let should_poll = matches!( + rec.status, + MeetingRecordingStatus::Joining + | MeetingRecordingStatus::Recording + | MeetingRecordingStatus::Processing + ) && rec.recall_bot_id.is_some(); + + if should_poll { + // Try to poll Recall.ai for the latest status + if let Some(updated_rec) = poll_recall_for_status(db, config, &rec, user.id).await { + return Ok(Json(ApiResponse::new(StatusCode::OK.into(), updated_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")) + } + } +} + +/// Poll Recall.ai for the latest bot status and update our database. +/// +/// Returns the updated recording if status changed, None otherwise. +async fn poll_recall_for_status( + db: &sea_orm::DatabaseConnection, + config: &service::config::Config, + recording: &MeetingRecordingModel, + user_id: Id, +) -> Option { + let bot_id = recording.recall_bot_id.as_ref()?; + + debug!("Polling Recall.ai for bot: {}", bot_id); + + // Get user's Recall.ai API key + let user_integrations = match user_integration::find_by_user_id(db, user_id).await { + Ok(Some(ui)) => ui, + Ok(None) => { + debug!("No user integrations found for user {}", user_id); + return None; + } + Err(e) => { + warn!("Error fetching user integrations: {:?}", e); + return None; + } + }; + + let api_key = match user_integrations.recall_ai_api_key.as_ref() { + Some(key) => key, + None => { + debug!("No Recall.ai API key configured for user {}", user_id); + return None; + } + }; + + 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); + + // Create client and fetch bot status + let client = match RecallAiClient::new(api_key, region, config.recall_ai_base_domain()) { + Ok(c) => c, + Err(e) => { + warn!("Failed to create Recall.ai client: {:?}", e); + return None; + } + }; + + let status_response = match client.get_bot_status(bot_id).await { + Ok(r) => { + debug!("Recall.ai response: {:?}", r); + r + } + Err(e) => { + warn!("Failed to get bot status from Recall.ai: {:?}", e); + return None; + } + }; + + // Map Recall.ai status to our internal status + let latest_status_code = status_response + .status_changes + .last() + .map(|s| s.code.as_str()) + .unwrap_or("unknown"); + + let new_status = map_recall_status_code(latest_status_code); + + // Extract video URL using the helper method (handles nested structure) + let video_url = status_response.video_url(); + + debug!( + "Recall.ai bot {} - recordings count: {}, video_url extracted: {:?}", + bot_id, + status_response.recordings.len(), + video_url.as_ref().map(|u| &u[..50.min(u.len())]) + ); + + // Check if status changed or video URL is now available + if new_status == recording.status && video_url.is_none() { + debug!("No status change and no video URL - skipping update"); + return None; // No change + } + + info!( + "Recall.ai bot {} status: {} -> {:?}, video_url: {}", + bot_id, + latest_status_code, + new_status, + video_url.is_some() + ); + + // Update recording with new status + let mut updated = recording.clone(); + updated.status = new_status; + + // If recording is complete, capture video URL and duration + if let Some(url) = video_url { + updated.recording_url = Some(url); + updated.status = MeetingRecordingStatus::Processing; + updated.ended_at = Some(chrono::Utc::now().into()); + + // Get duration from the recording object + if let Some(duration) = status_response.duration_seconds() { + updated.duration_seconds = Some(duration); + } + } + + // Save the updated recording + let saved = MeetingRecordingApi::update(db, recording.id, updated) + .await + .ok()?; + + // If recording is complete with video URL, trigger AssemblyAI transcription + if saved.status == MeetingRecordingStatus::Processing { + if let Some(video_url) = &saved.recording_url { + match trigger_assemblyai_transcription(db, config, &saved, video_url).await { + Ok(_) => info!( + "AssemblyAI transcription triggered for recording {}", + saved.id + ), + Err(e) => warn!( + "Failed to trigger AssemblyAI for recording {}: {:?}", + saved.id, e + ), + } + } + } + + Some(saved) +} + +/// Map Recall.ai status codes to our internal status +fn map_recall_status_code(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, + } +} + +/// Trigger AssemblyAI transcription for a completed recording +async fn trigger_assemblyai_transcription( + db: &sea_orm::DatabaseConnection, + config: &service::config::Config, + recording: &MeetingRecordingModel, + video_url: &str, +) -> Result<(), Box> { + use domain::gateway::assembly_ai::{create_standard_transcript_request, AssemblyAiClient}; + use domain::transcription as TranscriptionApi; + use domain::transcription_status::TranscriptionStatus; + + // Check if transcription already exists for this recording + if let Some(existing) = TranscriptionApi::find_by_meeting_recording_id(db, recording.id).await? + { + debug!( + "Transcription {} already exists for recording {}", + existing.id, recording.id + ); + return Ok(()); + } + + // Get the coaching session to find the relationship + let session = CoachingSessionApi::find_by_id(db, recording.coaching_session_id).await?; + + // Get the coaching relationship to find the coach + let relationship = + CoachingRelationshipApi::find_by_id(db, session.coaching_relationship_id).await?; + + // Get the coach's user integrations + let user_integrations = user_integration::find_by_user_id(db, relationship.coach_id) + .await? + .ok_or("Coach has no integrations configured")?; + + // Get the AssemblyAI API key + let api_key = user_integrations + .assembly_ai_api_key + .as_ref() + .ok_or("AssemblyAI API key not configured for coach")?; + + // Create a transcription record + let mut transcription = TranscriptionApi::create(db, recording.id).await?; + transcription.status = TranscriptionStatus::Processing; + + // Build the webhook URL for AssemblyAI callbacks + let webhook_url = config + .webhook_base_url() + .map(|base| format!("{}/webhooks/assemblyai", base)); + let webhook_secret = config.webhook_secret().map(|s| s.to_string()); + + // Create AssemblyAI client and send transcription request + let client = AssemblyAiClient::new(api_key, config.assembly_ai_base_url())?; + + let request = + create_standard_transcript_request(video_url.to_string(), webhook_url, webhook_secret); + + let response = client.create_transcript(request).await?; + + // Update transcription with AssemblyAI transcript ID + transcription.assemblyai_transcript_id = Some(response.id.clone()); + TranscriptionApi::update(db, transcription.id, transcription).await?; + + info!( + "Created AssemblyAI transcript {} for recording {}", + response.id, recording.id + ); + + Ok(()) +} diff --git a/web/src/controller/mod.rs b/web/src/controller/mod.rs index 67b93c1f..5ddba1a2 100644 --- a/web/src/controller/mod.rs +++ b/web/src/controller/mod.rs @@ -1,16 +1,22 @@ use serde::Serialize; pub(crate) mod action_controller; pub(crate) mod agreement_controller; +pub(crate) mod coaching_relationship_controller; 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; pub(crate) mod organization_controller; pub(crate) mod overarching_goal_controller; +pub(crate) mod transcription_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/oauth_controller.rs b/web/src/controller/oauth_controller.rs new file mode 100644 index 00000000..36f72618 --- /dev/null +++ b/web/src/controller/oauth_controller.rs @@ -0,0 +1,222 @@ +//! Controller for OAuth authentication flows. +//! +//! Handles Google OAuth for Google Meet integration. + +use crate::extractors::authenticated_user::AuthenticatedUser; +use crate::extractors::compare_api_version::CompareApiVersion; +use crate::{AppState, Error}; + +use axum::extract::{Query, State}; +use axum::response::{IntoResponse, Redirect}; + +use domain::gateway::google_oauth::{GoogleOAuthClient, GoogleOAuthUrls}; +use domain::user_integrations::Model as UserIntegrationModel; +use domain::{user_integration, Id}; +use log::*; +use serde::Deserialize; +use service::config::ApiVersion; + +/// Query parameters for OAuth callback +#[derive(Debug, Deserialize)] +pub struct OAuthCallback { + pub code: String, + pub state: Option, +} + +/// Query parameters for starting OAuth +#[derive(Debug, Deserialize)] +pub struct OAuthStart { + pub user_id: Id, +} + +/// Helper to create an internal server 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()), + ), + }) +} + +/// 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::Invalid), + ), + }) +} + +/// GET /oauth/google/authorize +/// +/// Initiates Google OAuth flow by redirecting to Google's authorization endpoint. +#[utoipa::path( + get, + path = "/oauth/google/authorize", + params( + ApiVersion, + ("user_id" = Id, Query, description = "User ID to associate with Google account"), + ), + responses( + (status = 302, description = "Redirect to Google OAuth"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Server error (OAuth not configured)"), + ), + security( + ("cookie_auth" = []) + ) +)] +pub async fn authorize( + CompareApiVersion(_v): CompareApiVersion, + AuthenticatedUser(user): AuthenticatedUser, + State(app_state): State, + Query(params): Query, +) -> Result { + // Verify user is authorizing their own account + if user.id != params.user_id { + return Err(forbidden_error("Cannot authorize OAuth for another user")); + } + + let config = &app_state.config; + + // Check if Google OAuth is configured + let client_id = config.google_client_id().ok_or_else(|| { + warn!("Google OAuth not configured: missing client_id"); + internal_error("Google OAuth not configured") + })?; + + let redirect_uri = config.google_redirect_uri().ok_or_else(|| { + warn!("Google OAuth not configured: missing redirect_uri"); + internal_error("Google OAuth not configured") + })?; + + let urls = GoogleOAuthUrls { + auth_url: config.google_oauth_auth_url().to_string(), + token_url: config.google_oauth_token_url().to_string(), + userinfo_url: config.google_userinfo_url().to_string(), + }; + + let client = GoogleOAuthClient::new(&client_id, "", &redirect_uri, urls)?; + + // Use user ID as state parameter for security + let state = params.user_id.to_string(); + let auth_url = client.get_authorization_url(&state); + + info!("Redirecting user {} to Google OAuth", params.user_id); + Ok(Redirect::temporary(&auth_url)) +} + +/// GET /oauth/google/callback +/// +/// Handles the OAuth callback from Google after user authorization. +#[utoipa::path( + get, + path = "/oauth/google/callback", + params( + ApiVersion, + ("code" = String, Query, description = "Authorization code from Google"), + ("state" = Option, Query, description = "State parameter (user ID)"), + ), + responses( + (status = 302, description = "Redirect to settings page on success"), + (status = 400, description = "Invalid callback parameters"), + (status = 500, description = "Token exchange failed"), + ) +)] +pub async fn callback( + CompareApiVersion(_v): CompareApiVersion, + State(app_state): State, + Query(params): Query, +) -> Result { + let config = &app_state.config; + + // Extract user ID from state + let user_id: Id = params + .state + .as_ref() + .ok_or_else(|| bad_request_error("Missing state parameter"))? + .parse() + .map_err(|_| bad_request_error("Invalid state parameter"))?; + + info!("Processing Google OAuth callback for user {}", user_id); + + // Get OAuth configuration + let client_id = config + .google_client_id() + .ok_or_else(|| internal_error("Google OAuth not configured"))?; + + let client_secret = config + .google_client_secret() + .ok_or_else(|| internal_error("Google OAuth not configured"))?; + + let redirect_uri = config + .google_redirect_uri() + .ok_or_else(|| internal_error("Google OAuth not configured"))?; + + let urls = GoogleOAuthUrls { + auth_url: config.google_oauth_auth_url().to_string(), + token_url: config.google_oauth_token_url().to_string(), + userinfo_url: config.google_userinfo_url().to_string(), + }; + + let client = GoogleOAuthClient::new(&client_id, &client_secret, &redirect_uri, urls)?; + + // Exchange authorization code for tokens + let token_response = client.exchange_code(¶ms.code).await.map_err(|e| { + warn!( + "Failed to exchange OAuth code for user {}: {:?}", + user_id, e + ); + internal_error("Failed to complete Google authorization") + })?; + + // Get user info from Google + let user_info = client + .get_user_info(&token_response.access_token) + .await + .map_err(|e| { + warn!( + "Failed to get Google user info for user {}: {:?}", + user_id, e + ); + internal_error("Failed to get Google user info") + })?; + + // Store tokens in user integrations + let mut integration: UserIntegrationModel = + user_integration::get_or_create(app_state.db_conn_ref(), user_id).await?; + + integration.google_access_token = Some(token_response.access_token); + integration.google_refresh_token = token_response.refresh_token; + integration.google_token_expiry = + Some(chrono::Utc::now() + chrono::Duration::seconds(token_response.expires_in)) + .map(|dt| dt.into()); + integration.google_email = Some(user_info.email); + + let _updated: UserIntegrationModel = + user_integration::update(app_state.db_conn_ref(), integration.id, integration).await?; + + info!( + "Successfully stored Google OAuth tokens for user {}", + user_id + ); + + // Redirect to settings page + // The frontend URL should be configured, but we'll use a relative redirect for now + let redirect_url = "/settings/integrations?google=connected".to_string(); + Ok(Redirect::temporary(&redirect_url)) +} diff --git a/web/src/controller/transcription_controller.rs b/web/src/controller/transcription_controller.rs new file mode 100644 index 00000000..65297903 --- /dev/null +++ b/web/src/controller/transcription_controller.rs @@ -0,0 +1,313 @@ +//! Controller for transcript and transcription operations. +//! +//! Handles retrieval of transcripts and transcript segments for coaching sessions. + +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::coaching_relationship as CoachingRelationshipApi; +use domain::coaching_session as CoachingSessionApi; +use domain::meeting_recording as MeetingRecordingApi; +use domain::transcript_segment; +use domain::transcript_segments::Model as TranscriptSegmentModel; +use domain::transcription as TranscriptionApi; +use domain::transcriptions::Model as TranscriptionModel; +use domain::Id; +use log::*; +use service::config::ApiVersion; + +/// GET /coaching_sessions/{id}/transcript +/// +/// Get the transcription for a coaching session. +/// Returns the transcript with summary, full text, and metadata. +#[utoipa::path( + get, + path = "/coaching_sessions/{id}/transcript", + params( + ApiVersion, + ("id" = Id, Path, description = "Coaching session ID"), + ), + responses( + (status = 200, description = "Transcription retrieved", body = transcriptions::Model), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - not a participant in this session"), + (status = 404, description = "No transcription found for this session"), + ), + security( + ("cookie_auth" = []) + ) +)] +pub async fn get_transcript( + CompareApiVersion(_v): CompareApiVersion, + AuthenticatedUser(user): AuthenticatedUser, + State(app_state): State, + Path(session_id): Path, +) -> Result { + debug!("GET transcript for session: {session_id}"); + + let db = app_state.db_conn_ref(); + + // 1. Get the coaching session + let session = CoachingSessionApi::find_by_id(db, session_id).await?; + + // 2. Get the coaching relationship and verify access + let relationship = + CoachingRelationshipApi::find_by_id(db, session.coaching_relationship_id).await?; + + // Only coach or coachee can view the transcript + if relationship.coach_id != user.id && relationship.coachee_id != user.id { + warn!( + "User {} attempted to view transcript for session {} but is not a participant", + user.id, session_id + ); + return Err(Error::Domain(domain::error::Error { + source: None, + error_kind: domain::error::DomainErrorKind::Internal( + domain::error::InternalErrorKind::Entity(domain::error::EntityErrorKind::Other( + "Not authorized to view this transcript".to_string(), + )), + ), + })); + } + + // 3. Get the latest recording for this session + let recording = MeetingRecordingApi::find_latest_by_coaching_session_id(db, session_id) + .await? + .ok_or_else(|| { + Error::Domain(domain::error::Error { + source: None, + error_kind: domain::error::DomainErrorKind::Internal( + domain::error::InternalErrorKind::Entity( + domain::error::EntityErrorKind::NotFound, + ), + ), + }) + })?; + + // 4. Get the transcription for this recording + let transcription: TranscriptionModel = + TranscriptionApi::find_by_meeting_recording_id(db, recording.id) + .await? + .ok_or_else(|| { + Error::Domain(domain::error::Error { + source: None, + error_kind: domain::error::DomainErrorKind::Internal( + domain::error::InternalErrorKind::Entity( + domain::error::EntityErrorKind::NotFound, + ), + ), + }) + })?; + + Ok(Json(ApiResponse::new(StatusCode::OK.into(), transcription))) +} + +/// GET /coaching_sessions/{id}/transcript/segments +/// +/// Get the transcript segments (utterances) for a coaching session. +/// Returns speaker-labeled segments with timestamps. +#[utoipa::path( + get, + path = "/coaching_sessions/{id}/transcript/segments", + params( + ApiVersion, + ("id" = Id, Path, description = "Coaching session ID"), + ), + responses( + (status = 200, description = "Transcript segments retrieved", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - not a participant in this session"), + (status = 404, description = "No transcript found for this session"), + ), + security( + ("cookie_auth" = []) + ) +)] +pub async fn get_transcript_segments( + CompareApiVersion(_v): CompareApiVersion, + AuthenticatedUser(user): AuthenticatedUser, + State(app_state): State, + Path(session_id): Path, +) -> Result { + debug!("GET transcript segments for session: {session_id}"); + + let db = app_state.db_conn_ref(); + + // 1. Get the coaching session + let session = CoachingSessionApi::find_by_id(db, session_id).await?; + + // 2. Get the coaching relationship and verify access + let relationship = + CoachingRelationshipApi::find_by_id(db, session.coaching_relationship_id).await?; + + // Only coach or coachee can view the transcript + if relationship.coach_id != user.id && relationship.coachee_id != user.id { + warn!( + "User {} attempted to view transcript segments for session {} but is not a participant", + user.id, session_id + ); + return Err(Error::Domain(domain::error::Error { + source: None, + error_kind: domain::error::DomainErrorKind::Internal( + domain::error::InternalErrorKind::Entity(domain::error::EntityErrorKind::Other( + "Not authorized to view this transcript".to_string(), + )), + ), + })); + } + + // 3. Get the latest recording for this session + let recording = MeetingRecordingApi::find_latest_by_coaching_session_id(db, session_id) + .await? + .ok_or_else(|| { + Error::Domain(domain::error::Error { + source: None, + error_kind: domain::error::DomainErrorKind::Internal( + domain::error::InternalErrorKind::Entity( + domain::error::EntityErrorKind::NotFound, + ), + ), + }) + })?; + + // 4. Get the transcription for this recording + let transcription: TranscriptionModel = + TranscriptionApi::find_by_meeting_recording_id(db, recording.id) + .await? + .ok_or_else(|| { + Error::Domain(domain::error::Error { + source: None, + error_kind: domain::error::DomainErrorKind::Internal( + domain::error::InternalErrorKind::Entity( + domain::error::EntityErrorKind::NotFound, + ), + ), + }) + })?; + + // 5. Get the segments for this transcription + let segments: Vec = + transcript_segment::find_by_transcription_id(db, transcription.id).await?; + + Ok(Json(ApiResponse::new(StatusCode::OK.into(), segments))) +} + +/// GET /coaching_sessions/{id}/summary +/// +/// Get just the AI-generated summary for a coaching session. +/// This is a convenience endpoint that returns only the summary text. +#[utoipa::path( + get, + path = "/coaching_sessions/{id}/summary", + params( + ApiVersion, + ("id" = Id, Path, description = "Coaching session ID"), + ), + responses( + (status = 200, description = "Summary retrieved", body = SummaryResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - not a participant in this session"), + (status = 404, description = "No summary available for this session"), + ), + security( + ("cookie_auth" = []) + ) +)] +pub async fn get_session_summary( + CompareApiVersion(_v): CompareApiVersion, + AuthenticatedUser(user): AuthenticatedUser, + State(app_state): State, + Path(session_id): Path, +) -> Result { + debug!("GET summary for session: {session_id}"); + + let db = app_state.db_conn_ref(); + + // 1. Get the coaching session + let session = CoachingSessionApi::find_by_id(db, session_id).await?; + + // 2. Get the coaching relationship and verify access + let relationship = + CoachingRelationshipApi::find_by_id(db, session.coaching_relationship_id).await?; + + // Only coach or coachee can view the summary + if relationship.coach_id != user.id && relationship.coachee_id != user.id { + warn!( + "User {} attempted to view summary for session {} but is not a participant", + user.id, session_id + ); + return Err(Error::Domain(domain::error::Error { + source: None, + error_kind: domain::error::DomainErrorKind::Internal( + domain::error::InternalErrorKind::Entity(domain::error::EntityErrorKind::Other( + "Not authorized to view this summary".to_string(), + )), + ), + })); + } + + // 3. Get the latest recording for this session + let recording = MeetingRecordingApi::find_latest_by_coaching_session_id(db, session_id) + .await? + .ok_or_else(|| { + Error::Domain(domain::error::Error { + source: None, + error_kind: domain::error::DomainErrorKind::Internal( + domain::error::InternalErrorKind::Entity( + domain::error::EntityErrorKind::NotFound, + ), + ), + }) + })?; + + // 4. Get the transcription for this recording + let transcription: TranscriptionModel = + TranscriptionApi::find_by_meeting_recording_id(db, recording.id) + .await? + .ok_or_else(|| { + Error::Domain(domain::error::Error { + source: None, + error_kind: domain::error::DomainErrorKind::Internal( + domain::error::InternalErrorKind::Entity( + domain::error::EntityErrorKind::NotFound, + ), + ), + }) + })?; + + // 5. Check if we have a summary + let summary = transcription.summary.ok_or_else(|| { + Error::Domain(domain::error::Error { + source: None, + error_kind: domain::error::DomainErrorKind::Internal( + domain::error::InternalErrorKind::Entity(domain::error::EntityErrorKind::NotFound), + ), + }) + })?; + + Ok(Json(ApiResponse::new( + StatusCode::OK.into(), + SummaryResponse { + session_id, + summary, + word_count: transcription.word_count, + confidence_score: transcription.confidence_score, + }, + ))) +} + +/// Response for the summary endpoint +#[derive(Debug, serde::Serialize)] +pub struct SummaryResponse { + pub session_id: Id, + pub summary: String, + pub word_count: Option, + pub confidence_score: Option, +} diff --git a/web/src/controller/webhook_controller.rs b/web/src/controller/webhook_controller.rs new file mode 100644 index 00000000..eab60c0d --- /dev/null +++ b/web/src/controller/webhook_controller.rs @@ -0,0 +1,562 @@ +//! 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::coaching_relationship as CoachingRelationshipApi; +use domain::coaching_session as CoachingSessionApi; +use domain::gateway::assembly_ai::{ + create_standard_transcript_request, AssemblyAiClient, TranscriptStatus, +}; +use domain::meeting_recording as MeetingRecordingApi; +use domain::meeting_recording_status::MeetingRecordingStatus; +use domain::meeting_recordings::Model as MeetingRecordingModel; +use domain::transcript_segment::{self, SegmentInput}; +use domain::transcription as TranscriptionApi; +use domain::transcription_status::TranscriptionStatus; +use domain::transcriptions::Model as TranscriptionModel; +use domain::user_integration as UserIntegrationApi; +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::Processing; + 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 updated: MeetingRecordingModel = + MeetingRecordingApi::update(db, recording.id, updated_recording).await?; + + // If we have a video URL, trigger AssemblyAI transcription + if let Some(video_url) = payload.data.video_url.clone() { + // Look up the coach to get their AssemblyAI API key + match trigger_assemblyai_transcription( + db, + config, + updated.id, + updated.coaching_session_id, + &video_url, + ) + .await + { + Ok(_) => { + info!( + "AssemblyAI transcription triggered for recording {}", + updated.id + ); + } + Err(e) => { + warn!( + "Failed to trigger AssemblyAI transcription for recording {}: {:?}", + updated.id, e + ); + // Don't fail the webhook - recording is still saved + } + } + } + } + "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(), + }), + )) +} + +/// AssemblyAI webhook payload +/// +/// Note: AssemblyAI webhooks are notifications only - they don't include the +/// actual transcript data. We must fetch the full transcript via the API when +/// we receive a "completed" notification. +#[derive(Debug, Deserialize)] +pub struct AssemblyAiWebhookPayload { + /// The transcript ID - AssemblyAI sends this as "id" in webhook payload + #[serde(alias = "id")] + pub transcript_id: String, + /// Status: queued, processing, completed, error + pub status: TranscriptStatus, + /// Error message if failed + #[serde(default)] + pub error: Option, +} + +/// POST /webhooks/assemblyai +/// +/// Handles webhook callbacks from AssemblyAI when transcription is complete. +/// This endpoint validates via webhook secret header. +pub async fn assemblyai_webhook( + State(app_state): State, + headers: HeaderMap, + Json(payload): Json, +) -> Result { + debug!( + "Received AssemblyAI webhook for transcript: {}", + payload.transcript_id + ); + + 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 AssemblyAI webhook secret received"); + return Ok(( + StatusCode::UNAUTHORIZED, + Json(WebhookResponse { + status: "unauthorized".to_string(), + }), + )); + } + } + + // Find the transcription by AssemblyAI transcript ID + let transcription: Option = + TranscriptionApi::find_by_assemblyai_id(db, &payload.transcript_id).await?; + + let transcription = match transcription { + Some(t) => t, + None => { + warn!( + "Received webhook for unknown AssemblyAI transcript: {}", + payload.transcript_id + ); + return Ok(( + StatusCode::OK, + Json(WebhookResponse { + status: "ignored".to_string(), + }), + )); + } + }; + + match payload.status { + TranscriptStatus::Completed => { + info!( + "AssemblyAI transcription completed: {}, fetching full transcript...", + payload.transcript_id + ); + + // AssemblyAI webhooks are notifications only - they don't include the transcript data. + // We need to fetch the full transcript via the API. + let full_transcript = match fetch_assemblyai_transcript( + db, + config, + &transcription, + &payload.transcript_id, + ) + .await + { + Ok(t) => t, + Err(e) => { + warn!("Failed to fetch full transcript from AssemblyAI: {:?}", e); + // Mark as failed since we can't get the data + let _: TranscriptionModel = TranscriptionApi::update_status( + db, + transcription.id, + TranscriptionStatus::Failed, + Some(format!("Failed to fetch transcript: {}", e)), + ) + .await?; + return Ok(( + StatusCode::OK, + Json(WebhookResponse { + status: "fetch_failed".to_string(), + }), + )); + } + }; + + debug!( + "Fetched full transcript - has_text: {}, text_len: {}, has_chapters: {}, has_utterances: {}", + full_transcript.text.is_some(), + full_transcript.text.as_ref().map(|t| t.len()).unwrap_or(0), + full_transcript.chapters.is_some(), + full_transcript.utterances.is_some() + ); + + // Build summary from chapters if available + let summary = full_transcript.chapters.as_ref().map(|chapters| { + chapters + .iter() + .map(|c| format!("**{}**\n{}", c.headline, c.summary)) + .collect::>() + .join("\n\n") + }); + + // Calculate word count from full text + let word_count = full_transcript + .text + .as_ref() + .map(|t| t.split_whitespace().count() as i32); + + debug!( + "AssemblyAI processing - summary_len: {}, word_count: {:?}", + summary.as_ref().map(|s| s.len()).unwrap_or(0), + word_count + ); + + // Update transcription with completed data + let mut updated = transcription.clone(); + updated.status = TranscriptionStatus::Completed; + updated.full_text = full_transcript.text.clone(); + updated.summary = summary; + updated.confidence_score = full_transcript.confidence; + updated.word_count = word_count; + + let updated_transcription: TranscriptionModel = + TranscriptionApi::update(db, transcription.id, updated).await?; + + info!( + "Updated transcription {} - has_full_text: {}, has_summary: {}", + updated_transcription.id, + updated_transcription.full_text.is_some(), + updated_transcription.summary.is_some() + ); + + // Store transcript segments (utterances) if available + if let Some(utterances) = full_transcript.utterances { + let utterance_count = utterances.len(); + let segments: Vec = utterances + .into_iter() + .map(|u| SegmentInput { + speaker_label: u.speaker.clone(), + text: u.text, + start_time_ms: u.start, + end_time_ms: u.end, + confidence: Some(u.confidence), + sentiment: None, // Would need sentiment_analysis_results for per-segment sentiment + }) + .collect(); + + if !segments.is_empty() { + let _ = + transcript_segment::create_batch(db, updated_transcription.id, segments) + .await?; + info!( + "Created {} transcript segments for transcription {}", + utterance_count, updated_transcription.id + ); + } + } + + // Update meeting recording status to completed + let _: MeetingRecordingModel = MeetingRecordingApi::update_status( + db, + transcription.meeting_recording_id, + MeetingRecordingStatus::Completed, + None, + ) + .await?; + } + TranscriptStatus::Error => { + warn!("AssemblyAI transcription failed: {}", payload.transcript_id); + + let _: TranscriptionModel = TranscriptionApi::update_status( + db, + transcription.id, + TranscriptionStatus::Failed, + payload.error.clone(), + ) + .await?; + + // Update meeting recording with error + let _: MeetingRecordingModel = MeetingRecordingApi::update_status( + db, + transcription.meeting_recording_id, + MeetingRecordingStatus::Failed, + payload.error, + ) + .await?; + } + TranscriptStatus::Processing | TranscriptStatus::Queued => { + debug!( + "AssemblyAI transcription still processing: {}", + payload.transcript_id + ); + // No action needed - these are status updates during processing + } + } + + Ok(( + StatusCode::OK, + Json(WebhookResponse { + status: "ok".to_string(), + }), + )) +} + +/// Trigger AssemblyAI transcription for a completed recording. +/// +/// This looks up the coach's AssemblyAI API key and creates a transcription +/// request with the recording URL. +async fn trigger_assemblyai_transcription( + db: &sea_orm::DatabaseConnection, + config: &service::config::Config, + recording_id: domain::Id, + coaching_session_id: domain::Id, + video_url: &str, +) -> Result<(), Box> { + // 1. Get the coaching session to find the relationship + let session = CoachingSessionApi::find_by_id(db, coaching_session_id).await?; + + // 2. Get the coaching relationship to find the coach + let relationship = + CoachingRelationshipApi::find_by_id(db, session.coaching_relationship_id).await?; + + // 3. Get the coach's user integrations + let user_integrations = UserIntegrationApi::find_by_user_id(db, relationship.coach_id) + .await? + .ok_or("Coach has no integrations configured")?; + + // 4. Get the AssemblyAI API key + let api_key = user_integrations + .assembly_ai_api_key + .as_ref() + .ok_or("AssemblyAI API key not configured for coach")?; + + // 5. Create a transcription record + let mut transcription = TranscriptionApi::create(db, recording_id).await?; + transcription.status = TranscriptionStatus::Processing; + + // 6. Build the webhook URL for AssemblyAI callbacks + let webhook_url = config + .webhook_base_url() + .map(|base| format!("{}/webhooks/assemblyai", base)); + let webhook_secret = config.webhook_secret().map(|s| s.to_string()); + + // 7. Create AssemblyAI client and send transcription request + let client = AssemblyAiClient::new(api_key, config.assembly_ai_base_url())?; + + let request = + create_standard_transcript_request(video_url.to_string(), webhook_url, webhook_secret); + + let response = client.create_transcript(request).await?; + + // 8. Update transcription with AssemblyAI transcript ID + transcription.assemblyai_transcript_id = Some(response.id.clone()); + TranscriptionApi::update(db, transcription.id, transcription).await?; + + info!( + "Created AssemblyAI transcript {} for recording {}", + response.id, recording_id + ); + + Ok(()) +} + +/// Fetch the full transcript from AssemblyAI. +/// +/// AssemblyAI webhooks only notify that a transcript is ready - the actual +/// transcript data must be fetched via a separate API call. +async fn fetch_assemblyai_transcript( + db: &sea_orm::DatabaseConnection, + config: &service::config::Config, + transcription: &TranscriptionModel, + transcript_id: &str, +) -> Result< + domain::gateway::assembly_ai::TranscriptResponse, + Box, +> { + // 1. Get the meeting recording to find the coaching session + let recording = MeetingRecordingApi::find_by_id(db, transcription.meeting_recording_id).await?; + + // 2. Get the coaching session to find the relationship + let session = CoachingSessionApi::find_by_id(db, recording.coaching_session_id).await?; + + // 3. Get the coaching relationship to find the coach + let relationship = + CoachingRelationshipApi::find_by_id(db, session.coaching_relationship_id).await?; + + // 4. Get the coach's user integrations + let user_integrations = UserIntegrationApi::find_by_user_id(db, relationship.coach_id) + .await? + .ok_or("Coach has no integrations configured")?; + + // 5. Get the AssemblyAI API key + let api_key = user_integrations + .assembly_ai_api_key + .as_ref() + .ok_or("AssemblyAI API key not configured for coach")?; + + // 6. Create AssemblyAI client and fetch the full transcript + let client = AssemblyAiClient::new(api_key, config.assembly_ai_base_url())?; + let transcript = client.get_transcript(transcript_id).await?; + + Ok(transcript) +} diff --git a/web/src/params/coaching_relationship.rs b/web/src/params/coaching_relationship.rs new file mode 100644 index 00000000..3ab82571 --- /dev/null +++ b/web/src/params/coaching_relationship.rs @@ -0,0 +1,16 @@ +//! Parameters for coaching relationship endpoints. + +use domain::ai_privacy_level::AiPrivacyLevel; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +/// Parameters for updating a coaching relationship +#[derive(Debug, Default, Deserialize, Serialize, ToSchema)] +pub struct UpdateParams { + /// Google Meet URL for this coaching relationship + #[serde(default, skip_serializing_if = "Option::is_none")] + pub meeting_url: Option, + /// AI privacy level for this coaching relationship + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ai_privacy_level: Option, +} diff --git a/web/src/params/integration.rs b/web/src/params/integration.rs new file mode 100644 index 00000000..7047b79f --- /dev/null +++ b/web/src/params/integration.rs @@ -0,0 +1,66 @@ +//! Parameters for user integration endpoints. + +use chrono::{DateTime, FixedOffset}; +use domain::user_integrations; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +/// Parameters for updating user integrations +#[derive(Debug, Deserialize, Serialize, ToSchema)] +pub struct UpdateIntegrationParams { + /// Recall.ai API key (encrypted at rest) + #[serde(skip_serializing_if = "Option::is_none")] + pub recall_ai_api_key: Option, + /// Recall.ai region (e.g., "us-west-2", "us-east-1", "eu-west-1") + #[serde(skip_serializing_if = "Option::is_none")] + pub recall_ai_region: Option, + /// AssemblyAI API key (encrypted at rest) + #[serde(skip_serializing_if = "Option::is_none")] + pub assembly_ai_api_key: Option, +} + +/// Response for user integration status (without exposing keys) +#[derive(Debug, Serialize, ToSchema)] +pub struct IntegrationStatusResponse { + /// Whether Google account is connected + pub google_connected: bool, + /// Connected Google email (if any) + pub google_email: Option, + /// Whether Recall.ai is configured + pub recall_ai_configured: bool, + /// Recall.ai region + pub recall_ai_region: Option, + /// When Recall.ai was last verified + pub recall_ai_verified_at: Option, + /// Whether AssemblyAI is configured + pub assembly_ai_configured: bool, + /// When AssemblyAI was last verified + pub assembly_ai_verified_at: Option, +} + +impl From for IntegrationStatusResponse { + fn from(model: user_integrations::Model) -> Self { + Self { + google_connected: model.google_access_token.is_some(), + google_email: model.google_email, + recall_ai_configured: model.recall_ai_api_key.is_some(), + recall_ai_region: model.recall_ai_region, + recall_ai_verified_at: model + .recall_ai_verified_at + .map(|dt: DateTime| dt.to_rfc3339()), + assembly_ai_configured: model.assembly_ai_api_key.is_some(), + assembly_ai_verified_at: model + .assembly_ai_verified_at + .map(|dt: DateTime| dt.to_rfc3339()), + } + } +} + +/// Response for API key verification +#[derive(Debug, Serialize, ToSchema)] +pub struct VerifyApiKeyResponse { + /// Whether the API key is valid + pub valid: bool, + /// Optional message + pub message: Option, +} diff --git a/web/src/params/mod.rs b/web/src/params/mod.rs index f52849b1..92e9436a 100644 --- a/web/src/params/mod.rs +++ b/web/src/params/mod.rs @@ -13,7 +13,9 @@ pub(crate) mod action; pub(crate) mod agreement; +pub(crate) mod coaching_relationship; pub(crate) mod coaching_session; +pub(crate) mod integration; pub(crate) mod jwt; pub(crate) mod overarching_goal; pub(crate) mod sort; diff --git a/web/src/router.rs b/web/src/router.rs index 002f1e26..546d229b 100644 --- a/web/src/router.rs +++ b/web/src/router.rs @@ -9,9 +9,11 @@ use axum::{ use tower_http::services::ServeDir; use crate::controller::{ - action_controller, agreement_controller, coaching_session_controller, jwt_controller, - note_controller, organization, organization_controller, overarching_goal_controller, user, - user_controller, user_session_controller, + action_controller, agreement_controller, coaching_relationship_controller, + coaching_session_controller, integration_controller, jwt_controller, + meeting_recording_controller, note_controller, oauth_controller, organization, + organization_controller, overarching_goal_controller, transcription_controller, user, + user_controller, user_session_controller, webhook_controller, }; use utoipa::{ @@ -20,8 +22,6 @@ use utoipa::{ }; use utoipa_rapidoc::RapiDoc; -use self::organization::coaching_relationship_controller; - // This is the global definition of our OpenAPI spec. To be a part // of the rendered spec, a path and schema must be listed here. #[derive(OpenApi)] @@ -75,6 +75,12 @@ use self::organization::coaching_relationship_controller; 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, + transcription_controller::get_transcript, + transcription_controller::get_transcript_segments, + transcription_controller::get_session_summary, ), components( schemas( @@ -82,9 +88,12 @@ use self::organization::coaching_relationship_controller; 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, + domain::transcriptions::Model, + domain::transcript_segments::Model, domain::users::Model, domain::user::Credentials, params::user::UpdateParams, @@ -120,6 +129,7 @@ pub fn define_routes(app_state: AppState) -> Router { Router::new() .merge(action_routes(app_state.clone())) .merge(agreement_routes(app_state.clone())) + .merge(coaching_relationship_routes(app_state.clone())) .merge(health_routes()) .merge(organization_routes(app_state.clone())) .merge(note_routes(app_state.clone())) @@ -127,6 +137,8 @@ pub fn define_routes(app_state: AppState) -> Router { .merge(organization_user_routes(app_state.clone())) .merge(overarching_goal_routes(app_state.clone())) .merge(user_routes(app_state.clone())) + .merge(user_integrations_routes(app_state.clone())) + .merge(oauth_routes(app_state.clone())) .merge(user_password_routes(app_state.clone())) .merge(user_organizations_routes(app_state.clone())) .merge(user_actions_routes(app_state.clone())) @@ -135,6 +147,9 @@ 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(transcription_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")) @@ -261,7 +276,7 @@ fn organization_coaching_relationship_routes(app_state: AppState) -> Router { Router::new() .route( "/organizations/:organization_id/coaching_relationships", - post(coaching_relationship_controller::create), + post(organization::coaching_relationship_controller::create), ) .route_layer(from_fn_with_state( app_state.clone(), @@ -508,6 +523,105 @@ fn user_overarching_goals_routes(app_state: AppState) -> Router { .with_state(app_state) } +/// Routes for user integrations (API key management) +fn user_integrations_routes(app_state: AppState) -> Router { + Router::new() + .route( + "/users/:user_id/integrations", + get(integration_controller::read), + ) + .route( + "/users/:user_id/integrations", + put(integration_controller::update), + ) + .route( + "/users/:user_id/integrations/verify/recall-ai", + post(integration_controller::verify_recall_ai), + ) + .route( + "/users/:user_id/integrations/verify/assembly-ai", + post(integration_controller::verify_assembly_ai), + ) + .route( + "/users/:user_id/integrations/google", + delete(integration_controller::disconnect_google), + ) + .route_layer(from_fn(require_auth)) + .with_state(app_state) +} + +/// Routes for coaching relationships (direct, non-nested operations) +fn coaching_relationship_routes(app_state: AppState) -> Router { + Router::new() + .route( + "/coaching_relationships/:id", + put(coaching_relationship_controller::update), + ) + .route_layer(from_fn(require_auth)) + .with_state(app_state) +} + +/// Routes for Google OAuth flow +fn oauth_routes(app_state: AppState) -> Router { + Router::new() + .route("/oauth/google/authorize", get(oauth_controller::authorize)) + .route_layer(from_fn(require_auth)) + .merge( + // Callback doesn't require auth (user is redirected back from Google) + Router::new().route("/oauth/google/callback", get(oauth_controller::callback)), + ) + .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 transcript and transcription operations +fn transcription_routes(app_state: AppState) -> Router { + Router::new() + .route( + "/coaching_sessions/:id/transcript", + get(transcription_controller::get_transcript), + ) + .route( + "/coaching_sessions/:id/transcript/segments", + get(transcription_controller::get_transcript_segments), + ) + .route( + "/coaching_sessions/:id/summary", + get(transcription_controller::get_session_summary), + ) + .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)) + .route( + "/webhooks/assemblyai", + post(webhook_controller::assemblyai_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("./"))