From d24b18eb6bf7f430ada5260a31d6df8a78ebb64b Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Sat, 20 Dec 2025 15:34:43 -0600 Subject: [PATCH 1/8] Feat: Add AI meeting integration foundation (Phase 1) Add database schema, entities, and CRUD operations for the AI meeting recording and transcription integration: Database Migrations: - Add user_integrations table for encrypted API credentials - Add ai_privacy_level enum and meeting_url to coaching_relationships - Add meeting_recordings, transcriptions, transcript_segments tables - Add ai_suggested_items table for AI-detected actions/agreements Entity Definitions: - user_integrations (Google OAuth, Recall.ai, AssemblyAI credentials) - meeting_recordings (Recall.ai bot tracking) - transcriptions (AssemblyAI transcript data) - transcript_segments (speaker-diarized utterances) - ai_suggested_items (pending AI suggestions) - Enums: ai_privacy_level, meeting_recording_status, transcription_status, sentiment, ai_suggestion_type, ai_suggestion_status Entity API: - CRUD operations for user_integration, meeting_recording, transcription, and ai_suggested_item modules Other: - AES-256-GCM encryption utilities for API key storage - Config additions for external service credentials - Update coaching_relationships with meeting_url and ai_privacy_level Relates to: refactor-group/refactor-platform-fe#146 --- Cargo.lock | 107 ++++++ domain/Cargo.toml | 5 + domain/src/encryption.rs | 245 ++++++++++++++ domain/src/lib.rs | 8 + domain/src/user.rs | 2 + entity/src/ai_privacy_level.rs | 31 ++ entity/src/ai_suggested_items.rs | 67 ++++ entity/src/ai_suggestion.rs | 55 +++ entity/src/coaching_relationships.rs | 8 + entity/src/lib.rs | 13 + entity/src/meeting_recording_status.rs | 46 +++ entity/src/meeting_recordings.rs | 79 +++++ entity/src/prelude.rs | 7 + entity/src/sentiment.rs | 24 ++ entity/src/transcript_segments.rs | 80 +++++ entity/src/transcription_status.rs | 38 +++ entity/src/transcriptions.rs | 91 +++++ entity/src/user_integrations.rs | 61 ++++ entity_api/src/ai_suggested_item.rs | 158 +++++++++ entity_api/src/lib.rs | 19 ++ entity_api/src/meeting_recording.rs | 148 ++++++++ entity_api/src/transcription.rs | 138 ++++++++ entity_api/src/user_integration.rs | 112 +++++++ migration/src/lib.rs | 7 + .../m20251220_000001_add_user_integrations.rs | 69 ++++ ...002_add_meeting_fields_to_relationships.rs | 63 ++++ ...220_000003_add_meeting_recording_tables.rs | 316 ++++++++++++++++++ service/src/config.rs | 75 +++++ 28 files changed, 2072 insertions(+) create mode 100644 domain/src/encryption.rs create mode 100644 entity/src/ai_privacy_level.rs create mode 100644 entity/src/ai_suggested_items.rs create mode 100644 entity/src/ai_suggestion.rs create mode 100644 entity/src/meeting_recording_status.rs create mode 100644 entity/src/meeting_recordings.rs create mode 100644 entity/src/sentiment.rs create mode 100644 entity/src/transcript_segments.rs create mode 100644 entity/src/transcription_status.rs create mode 100644 entity/src/transcriptions.rs create mode 100644 entity/src/user_integrations.rs create mode 100644 entity_api/src/ai_suggested_item.rs create mode 100644 entity_api/src/meeting_recording.rs create mode 100644 entity_api/src/transcription.rs create mode 100644 entity_api/src/user_integration.rs create mode 100644 migration/src/m20251220_000001_add_user_integrations.rs create mode 100644 migration/src/m20251220_000002_add_meeting_fields_to_relationships.rs create mode 100644 migration/src/m20251220_000003_add_meeting_recording_tables.rs diff --git a/Cargo.lock b/Cargo.lock index 0a4e8eb5..e6094b75 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,18 +905,23 @@ 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", ] @@ -1194,6 +1254,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 +1678,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 +2040,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 +2296,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 +4128,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..698a4828 100644 --- a/domain/Cargo.toml +++ b/domain/Cargo.toml @@ -4,15 +4,20 @@ 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"] } [dependencies.sea-orm] 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/lib.rs b/domain/src/lib.rs index b33c0e35..1d084e08 100644 --- a/domain/src/lib.rs +++ b/domain/src/lib.rs @@ -15,11 +15,19 @@ 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_status, + meeting_recordings, sentiment, transcript_segments, transcription_status, transcriptions, + 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; 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..9e820e4d --- /dev/null +++ b/entity/src/ai_privacy_level.rs @@ -0,0 +1,31 @@ +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, +)] +#[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/lib.rs b/entity_api/src/lib.rs index b9e1bdeb..75718e15 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,12 @@ 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 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 +137,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 +153,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 +168,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/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..fe4bbcd0 100644 --- a/service/src/config.rs +++ b/service/src/config.rs @@ -146,6 +146,43 @@ 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, } impl Default for Config { @@ -210,6 +247,44 @@ 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() + } } impl ApiVersion { From a2fdca38a21cb29e6ccfddd4e21e0ab2486a2745 Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Sat, 20 Dec 2025 16:47:44 -0600 Subject: [PATCH 2/8] Feat: Add external API gateway clients and integration endpoints (Phase 3) Add gateway clients for Recall.ai, AssemblyAI, and Google OAuth with configurable base URLs. Create integration controller endpoints for API key verification and OAuth flow handling. --- Cargo.lock | 1 + domain/Cargo.toml | 1 + domain/src/coaching_relationship.rs | 2 +- domain/src/gateway/assembly_ai.rs | 321 +++++++++++++++ domain/src/gateway/google_oauth.rs | 372 ++++++++++++++++++ domain/src/gateway/mod.rs | 3 + domain/src/gateway/recall_ai.rs | 327 +++++++++++++++ domain/src/lib.rs | 4 +- entity_api/src/coaching_relationship.rs | 47 ++- service/src/config.rs | 64 +++ .../coaching_relationship_controller.rs | 81 ++++ web/src/controller/integration_controller.rs | 281 +++++++++++++ web/src/controller/mod.rs | 3 + web/src/controller/oauth_controller.rs | 222 +++++++++++ web/src/params/coaching_relationship.rs | 16 + web/src/params/integration.rs | 66 ++++ web/src/params/mod.rs | 2 + web/src/router.rs | 62 ++- 18 files changed, 1866 insertions(+), 9 deletions(-) create mode 100644 domain/src/gateway/assembly_ai.rs create mode 100644 domain/src/gateway/google_oauth.rs create mode 100644 domain/src/gateway/recall_ai.rs create mode 100644 web/src/controller/coaching_relationship_controller.rs create mode 100644 web/src/controller/integration_controller.rs create mode 100644 web/src/controller/oauth_controller.rs create mode 100644 web/src/params/coaching_relationship.rs create mode 100644 web/src/params/integration.rs diff --git a/Cargo.lock b/Cargo.lock index e6094b75..12861eb1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -923,6 +923,7 @@ dependencies = [ "service", "thiserror 2.0.12", "tokio", + "urlencoding", ] [[package]] diff --git a/domain/Cargo.toml b/domain/Cargo.toml index 698a4828..c3af2051 100644 --- a/domain/Cargo.toml +++ b/domain/Cargo.toml @@ -19,6 +19,7 @@ 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/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..a39d7fdc --- /dev/null +++ b/domain/src/gateway/recall_ai.rs @@ -0,0 +1,327 @@ +//! 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, +} + +/// Response from creating a bot +#[derive(Debug, Deserialize)] +pub struct CreateBotResponse { + pub id: String, + pub meeting_url: String, + pub status_changes: Vec, +} + +/// Bot status change +#[derive(Debug, Deserialize)] +pub struct StatusChange { + pub code: String, + pub created_at: String, +} + +/// Bot status response +#[derive(Debug, Deserialize)] +pub struct BotStatusResponse { + pub id: String, + pub status_changes: Vec, + #[serde(default)] + pub video_url: Option, + #[serde(default)] + pub meeting_metadata: Option, +} + +/// 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() { + let bot: CreateBotResponse = response.json().await.map_err(|e| { + warn!("Failed to parse Recall.ai response: {:?}", e); + Error { + source: Some(Box::new(e)), + error_kind: DomainErrorKind::External(ExternalErrorKind::Other( + "Invalid response from Recall.ai".to_string(), + )), + } + })?; + info!("Created Recall.ai bot with ID: {}", bot.id); + Ok(bot) + } 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)), + }) + } + } + + /// 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![ + "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 1d084e08..a6619281 100644 --- a/domain/src/lib.rs +++ b/domain/src/lib.rs @@ -19,7 +19,7 @@ pub use entity_api::{ pub use entity_api::{ ai_privacy_level, ai_suggested_items, ai_suggestion, meeting_recording_status, meeting_recordings, sentiment, transcript_segments, transcription_status, transcriptions, - user_integrations, + user_integration, user_integrations, }; pub mod action; @@ -35,4 +35,4 @@ pub mod organization; pub mod overarching_goal; pub mod user; -pub(crate) mod gateway; +pub mod gateway; 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/service/src/config.rs b/service/src/config.rs index fe4bbcd0..c61ec5da 100644 --- a/service/src/config.rs +++ b/service/src/config.rs @@ -183,6 +183,39 @@ pub struct Config { /// 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 { @@ -285,6 +318,37 @@ impl Config { 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/mod.rs b/web/src/controller/mod.rs index 67b93c1f..d2f84b97 100644 --- a/web/src/controller/mod.rs +++ b/web/src/controller/mod.rs @@ -1,10 +1,13 @@ 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 note_controller; +pub(crate) mod oauth_controller; pub(crate) mod organization; pub(crate) mod organization_controller; pub(crate) mod overarching_goal_controller; 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/params/coaching_relationship.rs b/web/src/params/coaching_relationship.rs new file mode 100644 index 00000000..ff155f9a --- /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, Deserialize, Serialize, ToSchema)] +pub struct UpdateParams { + /// Google Meet URL for this coaching relationship + #[serde(skip_serializing_if = "Option::is_none")] + pub meeting_url: Option, + /// AI privacy level for this coaching relationship + #[serde(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..c293f30d 100644 --- a/web/src/router.rs +++ b/web/src/router.rs @@ -9,8 +9,9 @@ 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, + action_controller, agreement_controller, coaching_relationship_controller, + coaching_session_controller, integration_controller, jwt_controller, note_controller, + oauth_controller, organization, organization_controller, overarching_goal_controller, user, user_controller, user_session_controller, }; @@ -20,8 +21,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)] @@ -120,6 +119,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 +127,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())) @@ -261,7 +263,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 +510,56 @@ 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) +} + // 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("./")) From 671f6280eb72da452f607c5d299cc8a0378a85d8 Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Sat, 20 Dec 2025 16:57:44 -0600 Subject: [PATCH 3/8] Feat: Add meeting recording controller and webhook handler (Phase 4) Add start/stop recording endpoints that integrate with Recall.ai bot for meeting capture. Add webhook handler for receiving recording status updates from Recall.ai. --- domain/src/lib.rs | 6 +- .../meeting_recording_controller.rs | 350 ++++++++++++++++++ web/src/controller/mod.rs | 2 + web/src/controller/webhook_controller.rs | 196 ++++++++++ web/src/router.rs | 39 +- 5 files changed, 587 insertions(+), 6 deletions(-) create mode 100644 web/src/controller/meeting_recording_controller.rs create mode 100644 web/src/controller/webhook_controller.rs diff --git a/domain/src/lib.rs b/domain/src/lib.rs index a6619281..39a6dbc5 100644 --- a/domain/src/lib.rs +++ b/domain/src/lib.rs @@ -17,9 +17,9 @@ pub use entity_api::{ // AI Meeting Integration re-exports pub use entity_api::{ - ai_privacy_level, ai_suggested_items, ai_suggestion, meeting_recording_status, - meeting_recordings, sentiment, transcript_segments, transcription_status, transcriptions, - user_integration, user_integrations, + ai_privacy_level, ai_suggested_items, ai_suggestion, meeting_recording, + meeting_recording_status, meeting_recordings, sentiment, transcript_segments, + transcription_status, transcriptions, user_integration, user_integrations, }; pub mod action; diff --git a/web/src/controller/meeting_recording_controller.rs b/web/src/controller/meeting_recording_controller.rs new file mode 100644 index 00000000..a1a37325 --- /dev/null +++ b/web/src/controller/meeting_recording_controller.rs @@ -0,0 +1,350 @@ +//! Controller for meeting recording operations. +//! +//! Handles starting, stopping, and querying meeting recordings via Recall.ai. + +use crate::controller::ApiResponse; +use crate::extractors::authenticated_user::AuthenticatedUser; +use crate::extractors::compare_api_version::CompareApiVersion; +use crate::{AppState, Error}; + +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::Json; + +use domain::ai_privacy_level::AiPrivacyLevel; +use domain::coaching_relationship as CoachingRelationshipApi; +use domain::coaching_session as CoachingSessionApi; +use domain::gateway::recall_ai::{create_standard_bot_request, RecallAiClient, RecallRegion}; +use domain::meeting_recording as MeetingRecordingApi; +use domain::meeting_recording_status::MeetingRecordingStatus; +use domain::meeting_recordings::Model as MeetingRecordingModel; +use domain::{user_integration, Id}; +use log::*; +use service::config::ApiVersion; + +/// Helper to create a forbidden error +fn forbidden_error(message: &str) -> Error { + Error::Domain(domain::error::Error { + source: None, + error_kind: domain::error::DomainErrorKind::Internal( + domain::error::InternalErrorKind::Entity(domain::error::EntityErrorKind::Other( + message.to_string(), + )), + ), + }) +} + +/// Helper to create a bad request error +fn bad_request_error(message: &str) -> Error { + Error::Domain(domain::error::Error { + source: None, + error_kind: domain::error::DomainErrorKind::Internal( + domain::error::InternalErrorKind::Entity(domain::error::EntityErrorKind::Other( + message.to_string(), + )), + ), + }) +} + +/// Helper to create an internal error +fn internal_error(message: &str) -> Error { + Error::Domain(domain::error::Error { + source: None, + error_kind: domain::error::DomainErrorKind::Internal( + domain::error::InternalErrorKind::Other(message.to_string()), + ), + }) +} + +/// GET /coaching_sessions/{id}/recording +/// +/// Get the current recording status for a coaching session. +#[utoipa::path( + get, + path = "/coaching_sessions/{id}/recording", + params( + ApiVersion, + ("id" = Id, Path, description = "Coaching session ID"), + ), + responses( + (status = 200, description = "Recording status retrieved", body = meeting_recordings::Model), + (status = 401, description = "Unauthorized"), + (status = 404, description = "No recording found for this session"), + ), + security( + ("cookie_auth" = []) + ) +)] +pub async fn get_recording_status( + CompareApiVersion(_v): CompareApiVersion, + AuthenticatedUser(_user): AuthenticatedUser, + State(app_state): State, + Path(session_id): Path, +) -> Result { + debug!("GET recording status for session: {session_id}"); + + let recording: Option = + MeetingRecordingApi::find_latest_by_coaching_session_id( + app_state.db_conn_ref(), + session_id, + ) + .await?; + + match recording { + Some(rec) => Ok(Json(ApiResponse::new(StatusCode::OK.into(), rec))), + None => Err(Error::Domain(domain::error::Error { + source: None, + error_kind: domain::error::DomainErrorKind::Internal( + domain::error::InternalErrorKind::Entity(domain::error::EntityErrorKind::NotFound), + ), + })), + } +} + +/// POST /coaching_sessions/{id}/recording/start +/// +/// Start recording a coaching session via Recall.ai bot. +/// Only the coach can start recording, and AI features must be enabled for the relationship. +#[utoipa::path( + post, + path = "/coaching_sessions/{id}/recording/start", + params( + ApiVersion, + ("id" = Id, Path, description = "Coaching session ID"), + ), + responses( + (status = 201, description = "Recording started successfully", body = meeting_recordings::Model), + (status = 400, description = "Cannot start recording (AI disabled or no meeting URL)"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Only the coach can start recording"), + ), + security( + ("cookie_auth" = []) + ) +)] +pub async fn start_recording( + CompareApiVersion(_v): CompareApiVersion, + AuthenticatedUser(user): AuthenticatedUser, + State(app_state): State, + Path(session_id): Path, +) -> Result { + info!("Starting recording for session: {session_id}"); + + let db = app_state.db_conn_ref(); + let config = &app_state.config; + + // 1. Get the coaching session + let session = CoachingSessionApi::find_by_id(db, session_id).await?; + + // 2. Get the coaching relationship + let relationship = + CoachingRelationshipApi::find_by_id(db, session.coaching_relationship_id).await?; + + // 3. Verify user is the coach + if relationship.coach_id != user.id { + warn!( + "User {} attempted to start recording for session {} but is not the coach", + user.id, session_id + ); + return Err(forbidden_error("Only the coach can start recording")); + } + + // 4. Check AI privacy level + if relationship.ai_privacy_level == AiPrivacyLevel::None { + return Err(bad_request_error( + "AI recording is disabled for this coaching relationship", + )); + } + + // 5. Check for meeting URL + let meeting_url = relationship.meeting_url.clone().ok_or_else(|| { + bad_request_error("No meeting URL configured for this coaching relationship") + })?; + + // 6. Get user's Recall.ai API key + let user_integrations = user_integration::find_by_user_id(db, user.id) + .await? + .ok_or_else(|| { + bad_request_error("No integrations configured. Please set up Recall.ai in Settings.") + })?; + + let api_key = user_integrations.recall_ai_api_key.clone().ok_or_else(|| { + bad_request_error("Recall.ai API key not configured. Please set up in Settings.") + })?; + + // 7. Determine region + let region_str = user_integrations + .recall_ai_region + .as_deref() + .unwrap_or("us-west-2"); + let region: RecallRegion = region_str.parse().unwrap_or(RecallRegion::UsWest2); + + // 8. Create meeting recording record + let mut recording: MeetingRecordingModel = MeetingRecordingApi::create(db, session_id).await?; + + // 9. Create Recall.ai bot + let client = RecallAiClient::new(&api_key, region, config.recall_ai_base_domain())?; + + // Build webhook URL for status updates + let webhook_url = config + .webhook_base_url() + .map(|base| format!("{}/webhooks/recall", base)); + + let bot_request = create_standard_bot_request( + meeting_url, + "Refactor Coaching Notetaker".to_string(), + webhook_url, + ); + + match client.create_bot(bot_request).await { + Ok(response) => { + info!( + "Recall.ai bot created: {} for session {}", + response.id, session_id + ); + + // Update recording with bot ID and status + recording.recall_bot_id = Some(response.id); + recording.status = MeetingRecordingStatus::Joining; + recording.started_at = Some(chrono::Utc::now().into()); + + let updated: MeetingRecordingModel = + MeetingRecordingApi::update(db, recording.id, recording).await?; + + Ok(( + StatusCode::CREATED, + Json(ApiResponse::new(StatusCode::CREATED.into(), updated)), + )) + } + Err(e) => { + warn!( + "Failed to create Recall.ai bot for session {}: {:?}", + session_id, e + ); + + // Update recording with error status + recording.status = MeetingRecordingStatus::Failed; + recording.error_message = Some(format!("Failed to create bot: {:?}", e)); + let _ = MeetingRecordingApi::update(db, recording.id, recording).await; + + Err(internal_error("Failed to start recording")) + } + } +} + +/// POST /coaching_sessions/{id}/recording/stop +/// +/// Stop an active recording for a coaching session. +/// Only the coach can stop recording. +#[utoipa::path( + post, + path = "/coaching_sessions/{id}/recording/stop", + params( + ApiVersion, + ("id" = Id, Path, description = "Coaching session ID"), + ), + responses( + (status = 200, description = "Recording stopped successfully", body = meeting_recordings::Model), + (status = 400, description = "No active recording to stop"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Only the coach can stop recording"), + ), + security( + ("cookie_auth" = []) + ) +)] +pub async fn stop_recording( + CompareApiVersion(_v): CompareApiVersion, + AuthenticatedUser(user): AuthenticatedUser, + State(app_state): State, + Path(session_id): Path, +) -> Result { + info!("Stopping recording for session: {session_id}"); + + let db = app_state.db_conn_ref(); + let config = &app_state.config; + + // 1. Get the coaching session + let session = CoachingSessionApi::find_by_id(db, session_id).await?; + + // 2. Get the coaching relationship + let relationship = + CoachingRelationshipApi::find_by_id(db, session.coaching_relationship_id).await?; + + // 3. Verify user is the coach + if relationship.coach_id != user.id { + warn!( + "User {} attempted to stop recording for session {} but is not the coach", + user.id, session_id + ); + return Err(forbidden_error("Only the coach can stop recording")); + } + + // 4. Get the active recording + let recording: MeetingRecordingModel = + MeetingRecordingApi::find_latest_by_coaching_session_id(db, session_id) + .await? + .ok_or_else(|| bad_request_error("No recording found for this session"))?; + + // 5. Check if recording is active + if !matches!( + recording.status, + MeetingRecordingStatus::Joining | MeetingRecordingStatus::Recording + ) { + return Err(bad_request_error("No active recording to stop")); + } + + // 6. Get bot ID + let bot_id = recording + .recall_bot_id + .clone() + .ok_or_else(|| internal_error("Recording has no associated bot ID"))?; + + // 7. Get user's Recall.ai API key + let user_integrations = user_integration::find_by_user_id(db, user.id) + .await? + .ok_or_else(|| internal_error("User integrations not found"))?; + + let api_key = user_integrations + .recall_ai_api_key + .clone() + .ok_or_else(|| internal_error("Recall.ai API key not found"))?; + + // 8. Determine region + let region_str = user_integrations + .recall_ai_region + .as_deref() + .unwrap_or("us-west-2"); + let region: RecallRegion = region_str.parse().unwrap_or(RecallRegion::UsWest2); + + // 9. Stop the Recall.ai bot + let client = RecallAiClient::new(&api_key, region, config.recall_ai_base_domain())?; + + match client.stop_bot(&bot_id).await { + Ok(_) => { + info!( + "Recall.ai bot stopped: {} for session {}", + bot_id, session_id + ); + + // Update recording status + let updated: MeetingRecordingModel = MeetingRecordingApi::update_status( + db, + recording.id, + MeetingRecordingStatus::Processing, + None, + ) + .await?; + + Ok(Json(ApiResponse::new(StatusCode::OK.into(), updated))) + } + Err(e) => { + warn!( + "Failed to stop Recall.ai bot {} for session {}: {:?}", + bot_id, session_id, e + ); + Err(internal_error("Failed to stop recording")) + } + } +} diff --git a/web/src/controller/mod.rs b/web/src/controller/mod.rs index d2f84b97..9f833d95 100644 --- a/web/src/controller/mod.rs +++ b/web/src/controller/mod.rs @@ -6,6 +6,7 @@ pub(crate) mod coaching_session_controller; pub(crate) mod health_check_controller; pub(crate) mod integration_controller; pub(crate) mod jwt_controller; +pub(crate) mod meeting_recording_controller; pub(crate) mod note_controller; pub(crate) mod oauth_controller; pub(crate) mod organization; @@ -14,6 +15,7 @@ pub(crate) mod overarching_goal_controller; pub(crate) mod user; pub(crate) mod user_controller; pub(crate) mod user_session_controller; +pub(crate) mod webhook_controller; #[derive(Debug, Serialize)] struct ApiResponse { diff --git a/web/src/controller/webhook_controller.rs b/web/src/controller/webhook_controller.rs new file mode 100644 index 00000000..193c191a --- /dev/null +++ b/web/src/controller/webhook_controller.rs @@ -0,0 +1,196 @@ +//! Controller for handling webhooks from external services. +//! +//! Handles webhooks from Recall.ai for meeting recording status updates. + +use crate::{AppState, Error}; + +use axum::extract::State; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::IntoResponse; +use axum::Json; + +use domain::meeting_recording as MeetingRecordingApi; +use domain::meeting_recording_status::MeetingRecordingStatus; +use domain::meeting_recordings::Model as MeetingRecordingModel; +use log::*; +use serde::{Deserialize, Serialize}; + +/// Recall.ai webhook event payload +#[derive(Debug, Deserialize)] +pub struct RecallWebhookPayload { + /// The type of event + pub event: String, + /// The bot ID this event is for + pub data: RecallWebhookData, +} + +/// Data section of Recall.ai webhook +#[derive(Debug, Deserialize)] +pub struct RecallWebhookData { + /// Bot ID + pub bot_id: String, + /// Status code (for status change events) + pub status: Option, + /// Video URL (available when recording is complete) + pub video_url: Option, + /// Recording duration in seconds + pub duration: Option, + /// Error details if the bot failed + pub error: Option, +} + +/// Recall.ai bot status +#[derive(Debug, Deserialize)] +pub struct RecallBotStatus { + pub code: String, +} + +/// Recall.ai error details +#[derive(Debug, Deserialize)] +pub struct RecallError { + pub code: Option, + pub message: Option, +} + +/// Response for webhook acknowledgment +#[derive(Debug, Serialize)] +pub struct WebhookResponse { + pub status: String, +} + +/// Maps Recall.ai status codes to our internal status +fn map_recall_status(code: &str) -> MeetingRecordingStatus { + match code { + "ready" | "joining_call" => MeetingRecordingStatus::Joining, + "in_call_not_recording" | "in_waiting_room" => MeetingRecordingStatus::Joining, + "in_call_recording" => MeetingRecordingStatus::Recording, + "call_ended" | "done" => MeetingRecordingStatus::Processing, + "analysis_done" => MeetingRecordingStatus::Completed, + "fatal" | "error" => MeetingRecordingStatus::Failed, + _ => MeetingRecordingStatus::Pending, + } +} + +/// POST /webhooks/recall +/// +/// Handles webhook callbacks from Recall.ai for bot status updates. +/// This endpoint does not require authentication but validates via webhook secret. +pub async fn recall_webhook( + State(app_state): State, + headers: HeaderMap, + Json(payload): Json, +) -> Result { + debug!("Received Recall.ai webhook: {:?}", payload.event); + + let config = &app_state.config; + let db = app_state.db_conn_ref(); + + // Validate webhook secret if configured + if let Some(expected_secret) = config.webhook_secret() { + let provided_secret = headers + .get("x-webhook-secret") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + if provided_secret != expected_secret { + warn!("Invalid webhook secret received"); + return Ok(( + StatusCode::UNAUTHORIZED, + Json(WebhookResponse { + status: "unauthorized".to_string(), + }), + )); + } + } + + let bot_id = &payload.data.bot_id; + + // Find the recording by bot ID + let recording: Option = + MeetingRecordingApi::find_by_recall_bot_id(db, bot_id).await?; + + let recording = match recording { + Some(r) => r, + None => { + warn!("Received webhook for unknown bot ID: {}", bot_id); + return Ok(( + StatusCode::OK, + Json(WebhookResponse { + status: "ignored".to_string(), + }), + )); + } + }; + + // Handle different event types + match payload.event.as_str() { + "bot.status_change" => { + if let Some(status) = &payload.data.status { + let new_status = map_recall_status(&status.code); + info!( + "Bot {} status changed to {} (internal: {:?})", + bot_id, status.code, new_status + ); + + // Check for errors + let error_message = if new_status == MeetingRecordingStatus::Failed { + payload.data.error.as_ref().map(|e| { + format!( + "{}: {}", + e.code.as_deref().unwrap_or("unknown"), + e.message.as_deref().unwrap_or("Unknown error") + ) + }) + } else { + None + }; + + let _: MeetingRecordingModel = + MeetingRecordingApi::update_status(db, recording.id, new_status, error_message) + .await?; + } + } + "bot.done" | "recording.done" => { + info!("Bot {} recording completed", bot_id); + + // Update with video URL and duration if available + let mut updated_recording = recording.clone(); + updated_recording.status = MeetingRecordingStatus::Completed; + updated_recording.recording_url = payload.data.video_url.clone(); + updated_recording.duration_seconds = payload.data.duration; + updated_recording.ended_at = Some(chrono::Utc::now().into()); + + let _: MeetingRecordingModel = + MeetingRecordingApi::update(db, recording.id, updated_recording).await?; + } + "bot.error" | "recording.error" => { + warn!("Bot {} encountered an error", bot_id); + + let error_message = payload.data.error.as_ref().map(|e| { + format!( + "{}: {}", + e.code.as_deref().unwrap_or("unknown"), + e.message.as_deref().unwrap_or("Unknown error") + ) + }); + + let _: MeetingRecordingModel = MeetingRecordingApi::update_status( + db, + recording.id, + MeetingRecordingStatus::Failed, + error_message, + ) + .await?; + } + _ => { + debug!("Ignoring unhandled Recall.ai event: {}", payload.event); + } + } + + Ok(( + StatusCode::OK, + Json(WebhookResponse { + status: "ok".to_string(), + }), + )) +} diff --git a/web/src/router.rs b/web/src/router.rs index c293f30d..bc4519d4 100644 --- a/web/src/router.rs +++ b/web/src/router.rs @@ -10,9 +10,10 @@ use tower_http::services::ServeDir; use crate::controller::{ action_controller, agreement_controller, coaching_relationship_controller, - coaching_session_controller, integration_controller, jwt_controller, note_controller, - oauth_controller, organization, organization_controller, overarching_goal_controller, user, - user_controller, user_session_controller, + coaching_session_controller, integration_controller, jwt_controller, + meeting_recording_controller, note_controller, oauth_controller, organization, + organization_controller, overarching_goal_controller, user, user_controller, + user_session_controller, webhook_controller, }; use utoipa::{ @@ -74,6 +75,9 @@ use utoipa_rapidoc::RapiDoc; user::coaching_session_controller::index, user::overarching_goal_controller::index, jwt_controller::generate_collab_token, + meeting_recording_controller::get_recording_status, + meeting_recording_controller::start_recording, + meeting_recording_controller::stop_recording, ), components( schemas( @@ -81,6 +85,7 @@ use utoipa_rapidoc::RapiDoc; domain::agreements::Model, domain::coaching_sessions::Model, domain::coaching_relationships::Model, + domain::meeting_recordings::Model, domain::notes::Model, domain::organizations::Model, domain::overarching_goals::Model, @@ -137,6 +142,8 @@ pub fn define_routes(app_state: AppState) -> Router { .merge(user_session_routes()) .merge(user_session_protected_routes(app_state.clone())) .merge(coaching_sessions_routes(app_state.clone())) + .merge(meeting_recording_routes(app_state.clone())) + .merge(webhook_routes(app_state.clone())) .merge(jwt_routes(app_state.clone())) // **** FIXME: protect the OpenAPI web UI .merge(RapiDoc::with_openapi("/api-docs/openapi2.json", ApiDoc::openapi()).path("/rapidoc")) @@ -560,6 +567,32 @@ fn oauth_routes(app_state: AppState) -> Router { .with_state(app_state) } +/// Routes for meeting recording operations +fn meeting_recording_routes(app_state: AppState) -> Router { + Router::new() + .route( + "/coaching_sessions/:id/recording", + get(meeting_recording_controller::get_recording_status), + ) + .route( + "/coaching_sessions/:id/recording/start", + post(meeting_recording_controller::start_recording), + ) + .route( + "/coaching_sessions/:id/recording/stop", + post(meeting_recording_controller::stop_recording), + ) + .route_layer(from_fn(require_auth)) + .with_state(app_state) +} + +/// Routes for external service webhooks (no authentication - validated by webhook secret) +fn webhook_routes(app_state: AppState) -> Router { + Router::new() + .route("/webhooks/recall", post(webhook_controller::recall_webhook)) + .with_state(app_state) +} + // This will serve static files that we can use as a "fallback" for when the server panics pub fn static_routes() -> Router { Router::new().nest_service("/", ServeDir::new("./")) From 9810389d5a7cadef9023a1b593f4e719257ac017 Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Sat, 20 Dec 2025 17:12:35 -0600 Subject: [PATCH 4/8] Feat: Add transcription flow with AssemblyAI webhook (Phase 5) - Add transcript_segment entity_api module with CRUD operations - Add AssemblyAI webhook handler for transcription callbacks - Create transcription controller with transcript/segments/summary endpoints - Update router with transcription routes - Export transcript_segment and transcription modules from domain --- domain/src/lib.rs | 5 +- entity_api/src/lib.rs | 1 + entity_api/src/transcript_segment.rs | 151 +++++++++ web/src/controller/mod.rs | 1 + .../controller/transcription_controller.rs | 313 ++++++++++++++++++ web/src/controller/webhook_controller.rs | 215 ++++++++++++ web/src/router.rs | 33 +- 7 files changed, 715 insertions(+), 4 deletions(-) create mode 100644 entity_api/src/transcript_segment.rs create mode 100644 web/src/controller/transcription_controller.rs diff --git a/domain/src/lib.rs b/domain/src/lib.rs index 39a6dbc5..6bcbdc74 100644 --- a/domain/src/lib.rs +++ b/domain/src/lib.rs @@ -18,8 +18,9 @@ pub use entity_api::{ // 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_segments, - transcription_status, transcriptions, user_integration, user_integrations, + meeting_recording_status, meeting_recordings, sentiment, transcript_segment, + transcript_segments, transcription, transcription_status, transcriptions, user_integration, + user_integrations, }; pub mod action; diff --git a/entity_api/src/lib.rs b/entity_api/src/lib.rs index 75718e15..0b116429 100644 --- a/entity_api/src/lib.rs +++ b/entity_api/src/lib.rs @@ -30,6 +30,7 @@ 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; 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/web/src/controller/mod.rs b/web/src/controller/mod.rs index 9f833d95..5ddba1a2 100644 --- a/web/src/controller/mod.rs +++ b/web/src/controller/mod.rs @@ -12,6 +12,7 @@ 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; 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 index 193c191a..cd434a20 100644 --- a/web/src/controller/webhook_controller.rs +++ b/web/src/controller/webhook_controller.rs @@ -9,9 +9,14 @@ use axum::http::{HeaderMap, StatusCode}; use axum::response::IntoResponse; use axum::Json; +use domain::gateway::assembly_ai::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 log::*; use serde::{Deserialize, Serialize}; @@ -194,3 +199,213 @@ pub async fn recall_webhook( }), )) } + +/// AssemblyAI webhook payload +/// AssemblyAI sends the full transcript response when transcription is complete +#[derive(Debug, Deserialize)] +#[allow(dead_code)] // Fields are part of AssemblyAI API contract +pub struct AssemblyAiWebhookPayload { + /// The transcript ID + pub transcript_id: String, + /// Status: queued, processing, completed, error + pub status: TranscriptStatus, + /// Full text of the transcript (available when completed) + #[serde(default)] + pub text: Option, + /// Speaker-labeled utterances + #[serde(default)] + pub utterances: Option>, + /// Auto-generated chapters/summary + #[serde(default)] + pub chapters: Option>, + /// Confidence score + #[serde(default)] + pub confidence: Option, + /// Audio duration in milliseconds + #[serde(default)] + pub audio_duration: Option, + /// Error message if failed + #[serde(default)] + pub error: Option, +} + +/// AssemblyAI utterance (speaker segment) +#[derive(Debug, Deserialize)] +pub struct AssemblyAiUtterance { + pub text: String, + pub start: i64, + pub end: i64, + pub confidence: f64, + pub speaker: String, +} + +/// AssemblyAI chapter for summary +#[derive(Debug, Deserialize)] +#[allow(dead_code)] // Fields are part of AssemblyAI API contract +pub struct AssemblyAiChapter { + pub summary: String, + pub headline: String, + pub start: i64, + pub end: i64, + pub gist: String, +} + +/// 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: {}", + payload.transcript_id + ); + + // Build summary from chapters if available + let summary = payload.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 = payload + .text + .as_ref() + .map(|t| t.split_whitespace().count() as i32); + + // Update transcription with completed data + let mut updated = transcription.clone(); + updated.status = TranscriptionStatus::Completed; + updated.full_text = payload.text.clone(); + updated.summary = summary; + updated.confidence_score = payload.confidence; + updated.word_count = word_count; + + let updated_transcription: TranscriptionModel = + TranscriptionApi::update(db, transcription.id, updated).await?; + + // Store transcript segments (utterances) if available + if let Some(utterances) = payload.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(), + }), + )) +} diff --git a/web/src/router.rs b/web/src/router.rs index bc4519d4..546d229b 100644 --- a/web/src/router.rs +++ b/web/src/router.rs @@ -12,8 +12,8 @@ use crate::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, user, user_controller, - user_session_controller, webhook_controller, + organization_controller, overarching_goal_controller, transcription_controller, user, + user_controller, user_session_controller, webhook_controller, }; use utoipa::{ @@ -78,6 +78,9 @@ use utoipa_rapidoc::RapiDoc; 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( @@ -89,6 +92,8 @@ use utoipa_rapidoc::RapiDoc; 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, @@ -143,6 +148,7 @@ pub fn define_routes(app_state: AppState) -> Router { .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 @@ -586,10 +592,33 @@ fn meeting_recording_routes(app_state: AppState) -> Router { .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) } From c36b9a46fe9cf01aa768f53e568f3e93abbd3a16 Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Sat, 20 Dec 2025 17:32:48 -0600 Subject: [PATCH 5/8] Feat: Add AI suggestion controller with accept/dismiss flows (Phase 6) - Add ai_suggestion_controller with endpoints for suggestion management - Implement accept flow that creates Actions or Agreements from suggestions - Implement dismiss flow to mark suggestions as dismissed - Export ai_suggested_item module from domain - Add routes for GET /coaching_sessions/:id/ai-suggestions - Add routes for POST /ai-suggestions/:id/accept and /dismiss --- domain/src/lib.rs | 2 +- .../controller/ai_suggestion_controller.rs | 322 ++++++++++++++++++ web/src/controller/mod.rs | 1 + web/src/router.rs | 30 +- 4 files changed, 351 insertions(+), 4 deletions(-) create mode 100644 web/src/controller/ai_suggestion_controller.rs diff --git a/domain/src/lib.rs b/domain/src/lib.rs index 6bcbdc74..9b73fed5 100644 --- a/domain/src/lib.rs +++ b/domain/src/lib.rs @@ -17,7 +17,7 @@ pub use entity_api::{ // AI Meeting Integration re-exports pub use entity_api::{ - ai_privacy_level, ai_suggested_items, ai_suggestion, meeting_recording, + ai_privacy_level, ai_suggested_item, 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, diff --git a/web/src/controller/ai_suggestion_controller.rs b/web/src/controller/ai_suggestion_controller.rs new file mode 100644 index 00000000..afee86b4 --- /dev/null +++ b/web/src/controller/ai_suggestion_controller.rs @@ -0,0 +1,322 @@ +//! Controller for AI suggestion operations. +//! +//! Handles retrieving, accepting, and dismissing AI-suggested actions and agreements. + +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::actions::Model as ActionModel; +use domain::agreements::Model as AgreementModel; +use domain::ai_suggested_item as AiSuggestionApi; +use domain::ai_suggested_items::Model as AiSuggestionModel; +use domain::ai_suggestion::{AiSuggestionStatus, AiSuggestionType}; +use domain::coaching_relationship as CoachingRelationshipApi; +use domain::coaching_session as CoachingSessionApi; +use domain::meeting_recording as MeetingRecordingApi; +use domain::status::Status; +use domain::transcription as TranscriptionApi; +use domain::{action as ActionApi, agreement as AgreementApi, Id}; +use log::*; +use service::config::ApiVersion; + +/// GET /coaching_sessions/{id}/ai-suggestions +/// +/// Get pending AI suggestions for a coaching session. +/// Returns actions and agreements detected by AI from the transcript. +#[utoipa::path( + get, + path = "/coaching_sessions/{id}/ai-suggestions", + params( + ApiVersion, + ("id" = Id, Path, description = "Coaching session ID"), + ), + responses( + (status = 200, description = "AI suggestions retrieved", body = Vec), + (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_session_suggestions( + CompareApiVersion(_v): CompareApiVersion, + AuthenticatedUser(user): AuthenticatedUser, + State(app_state): State, + Path(session_id): Path, +) -> Result { + debug!("GET AI suggestions 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 suggestions + if relationship.coach_id != user.id && relationship.coachee_id != user.id { + warn!( + "User {} attempted to view AI suggestions for session {} but is not a participant", + user.id, session_id + ); + return Err(forbidden_error("Not authorized to view these suggestions")); + } + + // 3. Get the latest recording for this session + let recording = MeetingRecordingApi::find_latest_by_coaching_session_id(db, session_id) + .await? + .ok_or_else(|| not_found_error("No recording found for this session"))?; + + // 4. Get the transcription for this recording + let transcription = TranscriptionApi::find_by_meeting_recording_id(db, recording.id) + .await? + .ok_or_else(|| not_found_error("No transcription found for this session"))?; + + // 5. Get pending suggestions for this transcription + let suggestions: Vec = + AiSuggestionApi::find_pending_by_transcription_id(db, transcription.id).await?; + + Ok(Json(ApiResponse::new(StatusCode::OK.into(), suggestions))) +} + +/// POST /ai-suggestions/{id}/accept +/// +/// Accept an AI suggestion and create the corresponding Action or Agreement. +/// The suggestion will be linked to the newly created entity. +#[utoipa::path( + post, + path = "/ai-suggestions/{id}/accept", + params( + ApiVersion, + ("id" = Id, Path, description = "AI suggestion ID"), + ), + responses( + (status = 201, description = "Suggestion accepted and entity created", body = AcceptResponse), + (status = 400, description = "Suggestion already processed"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - not a participant in this session"), + (status = 404, description = "Suggestion not found"), + ), + security( + ("cookie_auth" = []) + ) +)] +pub async fn accept_suggestion( + CompareApiVersion(_v): CompareApiVersion, + AuthenticatedUser(user): AuthenticatedUser, + State(app_state): State, + Path(suggestion_id): Path, +) -> Result { + info!("Accepting AI suggestion: {suggestion_id}"); + + let db = app_state.db_conn_ref(); + + // 1. Get the suggestion + let suggestion = AiSuggestionApi::find_by_id(db, suggestion_id).await?; + + // 2. Verify suggestion is still pending + if suggestion.status != AiSuggestionStatus::Pending { + return Err(bad_request_error("Suggestion has already been processed")); + } + + // 3. Get the transcription to find the coaching session + let transcription = TranscriptionApi::find_by_id(db, suggestion.transcription_id).await?; + + // 4. Get the meeting recording + let recording = MeetingRecordingApi::find_by_id(db, transcription.meeting_recording_id).await?; + + // 5. Get the coaching session + let session = CoachingSessionApi::find_by_id(db, recording.coaching_session_id).await?; + + // 6. Verify user has access + let relationship = + CoachingRelationshipApi::find_by_id(db, session.coaching_relationship_id).await?; + + if relationship.coach_id != user.id && relationship.coachee_id != user.id { + warn!( + "User {} attempted to accept AI suggestion {} but is not a participant", + user.id, suggestion_id + ); + return Err(forbidden_error("Not authorized to accept this suggestion")); + } + + // 7. Create the entity based on suggestion type + let (entity_id, entity_type) = match suggestion.item_type { + AiSuggestionType::Action => { + let action_model = ActionModel { + id: Id::default(), + coaching_session_id: session.id, + user_id: user.id, + body: Some(suggestion.content.clone()), + due_by: None, + status: Status::NotStarted, + status_changed_at: chrono::Utc::now().into(), + created_at: chrono::Utc::now().into(), + updated_at: chrono::Utc::now().into(), + }; + + let created_action: ActionModel = ActionApi::create(db, action_model, user.id).await?; + info!("Created action {} from AI suggestion", created_action.id); + (created_action.id, "action") + } + AiSuggestionType::Agreement => { + let agreement_model = AgreementModel { + id: Id::default(), + coaching_session_id: session.id, + body: Some(suggestion.content.clone()), + user_id: user.id, + created_at: chrono::Utc::now().into(), + updated_at: chrono::Utc::now().into(), + }; + + let created_agreement: AgreementModel = + AgreementApi::create(db, agreement_model, user.id).await?; + info!( + "Created agreement {} from AI suggestion", + created_agreement.id + ); + (created_agreement.id, "agreement") + } + }; + + // 8. Update the suggestion as accepted + let updated_suggestion: AiSuggestionModel = + AiSuggestionApi::accept(db, suggestion_id, entity_id).await?; + + Ok(( + StatusCode::CREATED, + Json(ApiResponse::new( + StatusCode::CREATED.into(), + AcceptResponse { + suggestion: updated_suggestion, + entity_id, + entity_type: entity_type.to_string(), + }, + )), + )) +} + +/// POST /ai-suggestions/{id}/dismiss +/// +/// Dismiss an AI suggestion. The suggestion will be marked as dismissed +/// and will no longer appear in the pending list. +#[utoipa::path( + post, + path = "/ai-suggestions/{id}/dismiss", + params( + ApiVersion, + ("id" = Id, Path, description = "AI suggestion ID"), + ), + responses( + (status = 200, description = "Suggestion dismissed", body = ai_suggested_items::Model), + (status = 400, description = "Suggestion already processed"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - not a participant in this session"), + (status = 404, description = "Suggestion not found"), + ), + security( + ("cookie_auth" = []) + ) +)] +pub async fn dismiss_suggestion( + CompareApiVersion(_v): CompareApiVersion, + AuthenticatedUser(user): AuthenticatedUser, + State(app_state): State, + Path(suggestion_id): Path, +) -> Result { + info!("Dismissing AI suggestion: {suggestion_id}"); + + let db = app_state.db_conn_ref(); + + // 1. Get the suggestion + let suggestion = AiSuggestionApi::find_by_id(db, suggestion_id).await?; + + // 2. Verify suggestion is still pending + if suggestion.status != AiSuggestionStatus::Pending { + return Err(bad_request_error("Suggestion has already been processed")); + } + + // 3. Get the transcription to find the coaching session + let transcription = TranscriptionApi::find_by_id(db, suggestion.transcription_id).await?; + + // 4. Get the meeting recording + let recording = MeetingRecordingApi::find_by_id(db, transcription.meeting_recording_id).await?; + + // 5. Get the coaching session + let session = CoachingSessionApi::find_by_id(db, recording.coaching_session_id).await?; + + // 6. Verify user has access + let relationship = + CoachingRelationshipApi::find_by_id(db, session.coaching_relationship_id).await?; + + if relationship.coach_id != user.id && relationship.coachee_id != user.id { + warn!( + "User {} attempted to dismiss AI suggestion {} but is not a participant", + user.id, suggestion_id + ); + return Err(forbidden_error("Not authorized to dismiss this suggestion")); + } + + // 7. Dismiss the suggestion + let updated_suggestion: AiSuggestionModel = AiSuggestionApi::dismiss(db, suggestion_id).await?; + + Ok(Json(ApiResponse::new( + StatusCode::OK.into(), + updated_suggestion, + ))) +} + +/// Response for accepting a suggestion +#[derive(Debug, serde::Serialize)] +pub struct AcceptResponse { + pub suggestion: AiSuggestionModel, + pub entity_id: Id, + pub entity_type: 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::Other( + message.to_string(), + )), + ), + }) +} + +/// Helper to create a not found error +fn not_found_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(), + )), + ), + }) +} diff --git a/web/src/controller/mod.rs b/web/src/controller/mod.rs index 5ddba1a2..117496cb 100644 --- a/web/src/controller/mod.rs +++ b/web/src/controller/mod.rs @@ -1,6 +1,7 @@ use serde::Serialize; pub(crate) mod action_controller; pub(crate) mod agreement_controller; +pub(crate) mod ai_suggestion_controller; pub(crate) mod coaching_relationship_controller; pub(crate) mod coaching_session_controller; pub(crate) mod health_check_controller; diff --git a/web/src/router.rs b/web/src/router.rs index 546d229b..9d832b4c 100644 --- a/web/src/router.rs +++ b/web/src/router.rs @@ -9,9 +9,9 @@ use axum::{ use tower_http::services::ServeDir; use crate::controller::{ - action_controller, agreement_controller, coaching_relationship_controller, - coaching_session_controller, integration_controller, jwt_controller, - meeting_recording_controller, note_controller, oauth_controller, organization, + action_controller, agreement_controller, ai_suggestion_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, }; @@ -81,6 +81,9 @@ use utoipa_rapidoc::RapiDoc; transcription_controller::get_transcript, transcription_controller::get_transcript_segments, transcription_controller::get_session_summary, + ai_suggestion_controller::get_session_suggestions, + ai_suggestion_controller::accept_suggestion, + ai_suggestion_controller::dismiss_suggestion, ), components( schemas( @@ -94,6 +97,7 @@ use utoipa_rapidoc::RapiDoc; domain::overarching_goals::Model, domain::transcriptions::Model, domain::transcript_segments::Model, + domain::ai_suggested_items::Model, domain::users::Model, domain::user::Credentials, params::user::UpdateParams, @@ -149,6 +153,7 @@ pub fn define_routes(app_state: AppState) -> Router { .merge(coaching_sessions_routes(app_state.clone())) .merge(meeting_recording_routes(app_state.clone())) .merge(transcription_routes(app_state.clone())) + .merge(ai_suggestion_routes(app_state.clone())) .merge(webhook_routes(app_state.clone())) .merge(jwt_routes(app_state.clone())) // **** FIXME: protect the OpenAPI web UI @@ -611,6 +616,25 @@ fn transcription_routes(app_state: AppState) -> Router { .with_state(app_state) } +/// Routes for AI suggestion operations (accept/dismiss) +fn ai_suggestion_routes(app_state: AppState) -> Router { + Router::new() + .route( + "/coaching_sessions/:id/ai-suggestions", + get(ai_suggestion_controller::get_session_suggestions), + ) + .route( + "/ai-suggestions/:id/accept", + post(ai_suggestion_controller::accept_suggestion), + ) + .route( + "/ai-suggestions/:id/dismiss", + post(ai_suggestion_controller::dismiss_suggestion), + ) + .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() From 6cab8a87e5d8eac92391de531534fc6f0b16986b Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Sun, 21 Dec 2025 19:00:31 -0600 Subject: [PATCH 6/8] Fix: Resolve Recall.ai and AssemblyAI integration issues - Add nested response structures for Recall.ai video URL extraction (recordings[0].media_shortcuts.video_mixed.data.download_url) - Add backend polling for Recall.ai bot status when frontend requests - Fix AssemblyAI webhook field name mismatch (id vs transcript_id) - Fetch full transcript from AssemblyAI API on webhook notification (webhooks are notifications only, don't include transcript data) - Add comprehensive debug logging for troubleshooting --- domain/src/gateway/recall_ai.rs | 138 +++++++++- .../meeting_recording_controller.rs | 245 ++++++++++++++++- web/src/controller/webhook_controller.rs | 247 ++++++++++++++---- 3 files changed, 565 insertions(+), 65 deletions(-) diff --git a/domain/src/gateway/recall_ai.rs b/domain/src/gateway/recall_ai.rs index a39d7fdc..f352e690 100644 --- a/domain/src/gateway/recall_ai.rs +++ b/domain/src/gateway/recall_ai.rs @@ -98,11 +98,31 @@ pub struct RealtimeEndpoint { 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, - pub meeting_url: 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, } @@ -110,7 +130,8 @@ pub struct CreateBotResponse { #[derive(Debug, Deserialize)] pub struct StatusChange { pub code: String, - pub created_at: String, + #[serde(default)] + pub created_at: Option, } /// Bot status response @@ -118,12 +139,91 @@ pub struct StatusChange { pub struct BotStatusResponse { pub id: String, pub status_changes: Vec, + /// Recordings array containing media artifacts #[serde(default)] - pub video_url: Option, + 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 { @@ -210,23 +310,40 @@ impl RecallAiClient { })?; if response.status().is_success() { - let bot: CreateBotResponse = response.json().await.map_err(|e| { - warn!("Failed to parse Recall.ai response: {:?}", e); + // 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::Other( - "Invalid response from Recall.ai".to_string(), - )), + 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: {}", error_text); + warn!("Recall.ai API error ({}): {}", status, error_text); Err(Error { source: None, - error_kind: DomainErrorKind::External(ExternalErrorKind::Other(error_text)), + error_kind: DomainErrorKind::External(ExternalErrorKind::Other(format!( + "Recall.ai API error ({}): {}", + status, error_text + ))), }) } } @@ -313,6 +430,7 @@ pub fn create_standard_bot_request( endpoint_type: "webhook".to_string(), url, events: vec![ + // Real-time transcript events (during recording) "transcript.data".to_string(), "transcript.partial_data".to_string(), ], diff --git a/web/src/controller/meeting_recording_controller.rs b/web/src/controller/meeting_recording_controller.rs index a1a37325..e6cbd36d 100644 --- a/web/src/controller/meeting_recording_controller.rs +++ b/web/src/controller/meeting_recording_controller.rs @@ -78,21 +78,37 @@ fn internal_error(message: &str) -> Error { )] pub async fn get_recording_status( CompareApiVersion(_v): CompareApiVersion, - AuthenticatedUser(_user): AuthenticatedUser, + 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( - app_state.db_conn_ref(), - session_id, - ) - .await?; + MeetingRecordingApi::find_latest_by_coaching_session_id(db, session_id).await?; match recording { - Some(rec) => Ok(Json(ApiResponse::new(StatusCode::OK.into(), rec))), + 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( @@ -348,3 +364,218 @@ pub async fn 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/webhook_controller.rs b/web/src/controller/webhook_controller.rs index cd434a20..eab60c0d 100644 --- a/web/src/controller/webhook_controller.rs +++ b/web/src/controller/webhook_controller.rs @@ -9,7 +9,11 @@ use axum::http::{HeaderMap, StatusCode}; use axum::response::IntoResponse; use axum::Json; -use domain::gateway::assembly_ai::TranscriptStatus; +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; @@ -17,6 +21,7 @@ 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}; @@ -160,13 +165,41 @@ pub async fn recall_webhook( // Update with video URL and duration if available let mut updated_recording = recording.clone(); - updated_recording.status = MeetingRecordingStatus::Completed; + 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 _: MeetingRecordingModel = + 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); @@ -201,55 +234,22 @@ pub async fn recall_webhook( } /// AssemblyAI webhook payload -/// AssemblyAI sends the full transcript response when transcription is complete +/// +/// 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)] -#[allow(dead_code)] // Fields are part of AssemblyAI API contract pub struct AssemblyAiWebhookPayload { - /// The transcript ID + /// 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, - /// Full text of the transcript (available when completed) - #[serde(default)] - pub text: Option, - /// Speaker-labeled utterances - #[serde(default)] - pub utterances: Option>, - /// Auto-generated chapters/summary - #[serde(default)] - pub chapters: Option>, - /// Confidence score - #[serde(default)] - pub confidence: Option, - /// Audio duration in milliseconds - #[serde(default)] - pub audio_duration: Option, /// Error message if failed #[serde(default)] pub error: Option, } -/// AssemblyAI utterance (speaker segment) -#[derive(Debug, Deserialize)] -pub struct AssemblyAiUtterance { - pub text: String, - pub start: i64, - pub end: i64, - pub confidence: f64, - pub speaker: String, -} - -/// AssemblyAI chapter for summary -#[derive(Debug, Deserialize)] -#[allow(dead_code)] // Fields are part of AssemblyAI API contract -pub struct AssemblyAiChapter { - pub summary: String, - pub headline: String, - pub start: i64, - pub end: i64, - pub gist: String, -} - /// POST /webhooks/assemblyai /// /// Handles webhook callbacks from AssemblyAI when transcription is complete. @@ -308,12 +308,50 @@ pub async fn assemblyai_webhook( match payload.status { TranscriptStatus::Completed => { info!( - "AssemblyAI transcription completed: {}", + "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 = payload.chapters.as_ref().map(|chapters| { + let summary = full_transcript.chapters.as_ref().map(|chapters| { chapters .iter() .map(|c| format!("**{}**\n{}", c.headline, c.summary)) @@ -322,24 +360,37 @@ pub async fn assemblyai_webhook( }); // Calculate word count from full text - let word_count = payload + 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 = payload.text.clone(); + updated.full_text = full_transcript.text.clone(); updated.summary = summary; - updated.confidence_score = payload.confidence; + 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) = payload.utterances { + if let Some(utterances) = full_transcript.utterances { let utterance_count = utterances.len(); let segments: Vec = utterances .into_iter() @@ -409,3 +460,103 @@ pub async fn assemblyai_webhook( }), )) } + +/// 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) +} From a0cbd2f1dc42bceee445b75d3059e099dc17f083 Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Sun, 21 Dec 2025 19:00:53 -0600 Subject: [PATCH 7/8] Fix: Add serde attributes for proper JSON serialization - Add serde(rename_all = "snake_case") to AiPrivacyLevel enum - Add serde(default) to UpdateParams fields for optional deserialization - Add Default derive to UpdateParams struct --- entity/src/ai_privacy_level.rs | 1 + web/src/params/coaching_relationship.rs | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/entity/src/ai_privacy_level.rs b/entity/src/ai_privacy_level.rs index 9e820e4d..97389899 100644 --- a/entity/src/ai_privacy_level.rs +++ b/entity/src/ai_privacy_level.rs @@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize}; #[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 diff --git a/web/src/params/coaching_relationship.rs b/web/src/params/coaching_relationship.rs index ff155f9a..3ab82571 100644 --- a/web/src/params/coaching_relationship.rs +++ b/web/src/params/coaching_relationship.rs @@ -5,12 +5,12 @@ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; /// Parameters for updating a coaching relationship -#[derive(Debug, Deserialize, Serialize, ToSchema)] +#[derive(Debug, Default, Deserialize, Serialize, ToSchema)] pub struct UpdateParams { /// Google Meet URL for this coaching relationship - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "Option::is_none")] pub meeting_url: Option, /// AI privacy level for this coaching relationship - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "Option::is_none")] pub ai_privacy_level: Option, } From acfdf5b2a2da19f3cdbaec06c14458f45351243f Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Sun, 21 Dec 2025 22:20:58 -0600 Subject: [PATCH 8/8] Feat: Extract action items from transcript and create AI suggestions - Add action item extraction after transcription completes - Create AI suggestion records for each detected action item - Use keyword-based extraction (will, going to, need to, should, etc.) - Log extraction results for debugging --- web/src/controller/webhook_controller.rs | 41 +++++++++++++++++++++--- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/web/src/controller/webhook_controller.rs b/web/src/controller/webhook_controller.rs index eab60c0d..c0c6d194 100644 --- a/web/src/controller/webhook_controller.rs +++ b/web/src/controller/webhook_controller.rs @@ -9,10 +9,12 @@ use axum::http::{HeaderMap, StatusCode}; use axum::response::IntoResponse; use axum::Json; +use domain::ai_suggested_item as AiSuggestedItemApi; +use domain::ai_suggestion::AiSuggestionType; use domain::coaching_relationship as CoachingRelationshipApi; use domain::coaching_session as CoachingSessionApi; use domain::gateway::assembly_ai::{ - create_standard_transcript_request, AssemblyAiClient, TranscriptStatus, + create_standard_transcript_request, extract_action_items, AssemblyAiClient, TranscriptStatus, }; use domain::meeting_recording as MeetingRecordingApi; use domain::meeting_recording_status::MeetingRecordingStatus; @@ -390,13 +392,13 @@ pub async fn assemblyai_webhook( ); // Store transcript segments (utterances) if available - if let Some(utterances) = full_transcript.utterances { + if let Some(ref utterances) = full_transcript.utterances { let utterance_count = utterances.len(); let segments: Vec = utterances - .into_iter() + .iter() .map(|u| SegmentInput { speaker_label: u.speaker.clone(), - text: u.text, + text: u.text.clone(), start_time_ms: u.start, end_time_ms: u.end, confidence: Some(u.confidence), @@ -415,6 +417,37 @@ pub async fn assemblyai_webhook( } } + // Extract action items from transcript and create AI suggestions + let action_items = extract_action_items(&full_transcript); + if !action_items.is_empty() { + info!( + "Extracted {} action items from transcript {}", + action_items.len(), + updated_transcription.id + ); + + for action_text in action_items { + match AiSuggestedItemApi::create( + db, + updated_transcription.id, + AiSuggestionType::Action, + action_text.clone(), + Some(action_text), // source_text is the same as content for now + None, // confidence not available from simple extraction + ) + .await + { + Ok(suggestion) => { + debug!("Created AI suggestion: {}", suggestion.id); + } + Err(e) => { + warn!("Failed to create AI suggestion: {:?}", e); + // Don't fail the webhook - continue processing + } + } + } + } + // Update meeting recording status to completed let _: MeetingRecordingModel = MeetingRecordingApi::update_status( db,