From d24b18eb6bf7f430ada5260a31d6df8a78ebb64b Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Sat, 20 Dec 2025 15:34:43 -0600 Subject: [PATCH] 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 {